Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
17 changes: 17 additions & 0 deletions .github/workflows/unit.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -50,4 +50,21 @@ jobs:

- name: Run tests
run: vendor/bin/phpunit

js-unit:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6

- name: Setup Node
uses: actions/setup-node@v6
with:
node-version: 24
cache: npm

- name: Install dependencies
run: npm ci

- name: Run tests
run: npm test

52 changes: 40 additions & 12 deletions Resources/public/assets/app.js
Original file line number Diff line number Diff line change
@@ -1,5 +1,29 @@
const decoder = new TextDecoder();

function parseResultLine(line) {
try {
const result = JSON.parse(line);
if (result !== null && typeof result.success === 'boolean') {
return result;
}
} catch {
// not the final status line, treat as log output
}

return null;
}

function appendLogLines(element, lines) {
if (lines.length === 0) {
return;
}

// Append as a single text node instead of innerHTML += per line: re-parsing
// the accumulated log is quadratic and freezes the browser on long updates.
element.appendChild(document.createTextNode(lines.join("\n") + "\n"));
element.scrollTop = element.scrollHeight;
}

async function tailLog(response, element) {
const reader = response.body.getReader();
let buffer = '';
Expand All @@ -8,41 +32,45 @@ async function tailLog(response, element) {
const {value, done} = await reader.read();

if (done) {
buffer += decoder.decode();

if (buffer.trim()) {
try {
const result = JSON.parse(buffer);
const result = parseResultLine(buffer);
if (result) {
if (!result.success) {
throw new Error('update failed');
}
return result;
} catch {
element.innerHTML += `${buffer}\n`;
element.scrollTop = element.scrollHeight;
}

appendLogLines(element, [buffer]);
}
break;
}

const text = decoder.decode(value);
buffer += text;
buffer += decoder.decode(value, {stream: true});

const lines = buffer.split("\n");
buffer = lines.pop();

const logLines = [];
for (const line of lines) {
if (line.trim() === '') continue;

try {
const result = JSON.parse(line);
const result = parseResultLine(line);
if (result) {
appendLogLines(element, logLines);

if (!result.success) {
throw new Error('update failed');
}
return result;
} catch {
element.innerHTML += `${line}\n`;
element.scrollTop = element.scrollHeight;
}

logLines.push(line);
}

appendLogLines(element, logLines);
}

throw new Error('Unexpected end of stream');
Expand Down
154 changes: 154 additions & 0 deletions Tests/Javascript/TailLog.test.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,154 @@
import { beforeEach, describe, expect, it } from 'vitest';
import appJsSource from '../../Resources/public/assets/app.js?raw';

// app.js is a classic script without exports, so evaluate it and pull out tailLog.
function loadTailLog() {
return new Function(`${appJsSource}\nreturn tailLog;`)();
}

const encoder = new TextEncoder();

function mockResponse(chunks) {
let index = 0;

return {
body: {
getReader: () => ({
read: async () => index < chunks.length
? { value: chunks[index++], done: false }
: { value: undefined, done: true },
}),
},
};
}

function composerChunks(lineCount, linesPerChunk, statusLine) {
const chunks = [];

for (let i = 0; i < lineCount; i += linesPerChunk) {
let text = '';
for (let j = i; j < Math.min(i + linesPerChunk, lineCount); j++) {
text += ` - Upgrading shopware/package-${j} (6.7.0.0 => 6.7.1.0): Extracting archive\n`;
}
chunks.push(encoder.encode(text));
}

if (statusLine) {
chunks.push(encoder.encode(statusLine));
}

return chunks;
}

describe('tailLog', () => {
let tailLog;
let element;

beforeEach(() => {
tailLog = loadTailLog();
element = document.createElement('pre');
document.body.appendChild(element);
});

it('renders log lines and resolves with the status on success', async () => {
const response = mockResponse(composerChunks(50, 10, '{"success":true}'));

const result = await tailLog(response, element);

expect(result).toEqual({ success: true });
const lines = element.textContent.trim().split('\n');
expect(lines).toHaveLength(50);
expect(lines[0]).toContain('shopware/package-0');
expect(lines[49]).toContain('shopware/package-49');
});

it('throws "update failed" on a success:false status line', async () => {
const response = mockResponse(composerChunks(5, 5, '{"success":false}'));

await expect(tailLog(response, element)).rejects.toThrow('update failed');
expect(element.textContent.trim().split('\n')).toHaveLength(5);
});

it('throws "Unexpected end of stream" when the stream ends without a status line', async () => {
const response = mockResponse(composerChunks(3, 3, null));

await expect(tailLog(response, element)).rejects.toThrow('Unexpected end of stream');
});

it('handles a status line preceded by log output in the same chunk', async () => {
const response = mockResponse([encoder.encode('last log line\n{"success":true}')]);

const result = await tailLog(response, element);

expect(result).toEqual({ success: true });
expect(element.textContent).toContain('last log line');
});

it('decodes multi-byte UTF-8 characters split across chunks', async () => {
const bytes = encoder.encode('Größe geändert\n');
const response = mockResponse([
bytes.slice(0, 3), // ends mid-'ö' (0xC3 0xB6 at byte offsets 2-3)
bytes.slice(3),
encoder.encode('{"success":true}'),
]);

await tailLog(response, element);

expect(element.textContent).toContain('Größe geändert');
});

it('does not treat JSON-like log lines as the status line', async () => {
const response = mockResponse([
encoder.encode('123\n"quoted"\nnull\n{"other":true}\n'),
encoder.encode('{"success":true}'),
]);

const result = await tailLog(response, element);

expect(result).toEqual({ success: true });
expect(element.textContent.trim().split('\n')).toHaveLength(4);
});

it('renders log output as text, not as HTML', async () => {
const response = mockResponse([
encoder.encode('<img src=x onerror=alert(1)>\n'),
encoder.encode('{"success":true}'),
]);

await tailLog(response, element);

expect(element.querySelector('img')).toBeNull();
expect(element.textContent).toContain('<img src=x onerror=alert(1)>');
});

it('skips empty lines', async () => {
const response = mockResponse([
encoder.encode('first\n\n\nsecond\n'),
encoder.encode('{"success":true}'),
]);

await tailLog(response, element);

expect(element.textContent.trim().split('\n')).toHaveLength(2);
});

it('updates the DOM per chunk, not per line', async () => {
// Guards against the quadratic per-line rendering that froze the
// browser on long composer outputs (shopware/shopware#17236).
// Every render op ends with a scrollTop assignment, so count those.
let scrollUpdates = 0;
Object.defineProperty(element, 'scrollTop', {
get: () => 0,
set: () => {
scrollUpdates++;
},
});

const response = mockResponse(composerChunks(100, 10, '{"success":true}'));

await tailLog(response, element);

expect(element.textContent.trim().split('\n')).toHaveLength(100);
expect(scrollUpdates).toBeLessThanOrEqual(10);
});
});
1 change: 1 addition & 0 deletions box.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
"main": false,
"files": ["index.php"],
"directories": ["."],
"blacklist": ["node_modules"],
"finder": [
{
"name": "*",
Expand Down
Loading
Loading