Files
qwen-code/scripts/tests/export-html-from-chatrecord-jsonl.test.js
易良andyiliang114 948872b148 fix(web-shell): close the four deferred #9812 review follow-ups (#11107)
Each of the four suggestions deferred out of #9812 was left with nothing
pinning it, so the behaviour each one protects could regress silently.

Pin the historical sessionStorage key. `clientLifecycle.test.ts` only ever
round-tripped through `SESSION_CLIENT_ID_STORAGE_PREFIX`, so renaming that
constant moved the read and the write together and left every test green,
while a tab that persisted its id under the WebUI-era key lost it across the
migration. Three assertions now spell the key literally, including its
percent-encoded session suffix.

Make the ChatRecord export script testable and cover both paths. It had no
exports and ran `main()` at import, which is why the legacy-JSONL rejection —
a behaviour flip #9812 introduced — had no test. The input gate is now
`selectChatRecords`, the render is `renderHtmlFromObjects(objects, api)` with
the export API passed in, and `main()` runs only as the process entry point.
The CLI behaves as before. The new suite asserts the rejection message
verbatim and that the renderer is never reached, so removing the throw fails
rather than falling through to the neighbouring error.

Restore the substring-trap fixture the retirement deleted, under a live path.
`Shellfish.tsx` guards the keyword being the head of a stem, which is a
different loosening from the `web-shell` compound case already covered.

Replace the deleted `InputForm` in the public hook's docblock with
`ChatEditor`, which declares the three props, naming `ChatPane` and `App` as
the in-tree hosts.

Verification brief for the two vitest-backed items is committed alongside;
item 3 was run here, mutation included, and the mutant survives without the
new fixture.

Refs #11076, #9812.

Co-authored-by: yiliang114 <[email protected]>
2026-09-05 23:40:57 +00:00

192 lines
5.9 KiB
JavaScript

/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { afterEach, describe, expect, it, vi } from 'vitest';
import { mkdtempSync, rmSync, writeFileSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import {
looksLikeChatRecord,
looksLikeExportJsonl,
readJsonlObjects,
renderHtmlFromObjects,
selectChatRecords,
} from '../../integration-tests/concurrent-runner/export-html-from-chatrecord-jsonl.js';
const LEGACY_REJECTION =
'Legacy exported JSONL cannot be rendered safely; provide source ChatRecord JSONL.';
function chatRecord(overrides = {}) {
return {
uuid: 'rec-1',
parentUuid: null,
sessionId: 'sess-1',
timestamp: '2026-01-02T03:04:05.000Z',
type: 'user',
cwd: '/work',
version: '0.1.0',
...overrides,
};
}
function legacyMetadata(overrides = {}) {
return {
type: 'session_metadata',
sessionId: 'sess-1',
startTime: '2026-01-02T03:04:05.000Z',
...overrides,
};
}
/** Stand-in for `@qwen-code/qwen-code/export`, which needs built CLI output. */
function stubExportApi() {
return {
collectSessionMetadata: vi.fn(async (conversation) => ({
sessionId: conversation.sessionId,
messageCount: conversation.messages.length,
})),
toHtml: vi.fn(() => '<html>rendered</html>'),
};
}
const tempDirs = [];
function writeJsonl(lines) {
const dir = mkdtempSync(join(tmpdir(), 'chatrecord-jsonl-'));
tempDirs.push(dir);
const file = join(dir, 'input.jsonl');
writeFileSync(file, lines.join('\n'), 'utf8');
return file;
}
afterEach(() => {
while (tempDirs.length > 0) {
rmSync(tempDirs.pop(), { recursive: true, force: true });
}
});
describe('selectChatRecords', () => {
it('rejects legacy exported JSONL with the exact message', () => {
// The behaviour this pins is a deliberate flip: legacy exports used to be
// rendered through a separate path. Dropping the throw, or letting
// looksLikeExportJsonl stop matching the legacy shape, must not pass
// silently — that would put already-rendered markup back on the page
// without the export API's document allowlist ever seeing it.
expect(() => selectChatRecords([legacyMetadata()])).toThrow(
LEGACY_REJECTION,
);
});
it('rejects on the first line alone, even with real records behind it', () => {
expect(() => selectChatRecords([legacyMetadata(), chatRecord()])).toThrow(
LEGACY_REJECTION,
);
});
it('accepts ChatRecords and drops entries that are not one', () => {
const keep = chatRecord({ uuid: 'rec-keep' });
const records = selectChatRecords([keep, { note: 'not a record' }]);
expect(records).toEqual([keep]);
});
it('reports an empty input distinctly from an unrecognized one', () => {
expect(() => selectChatRecords([])).toThrow('Input JSONL is empty.');
expect(() => selectChatRecords([{ note: 'nope' }])).toThrow(
'Unrecognized JSONL format (expected ChatRecord-per-line).',
);
});
});
describe('renderHtmlFromObjects', () => {
it('renders the ChatRecord happy path through the export API', async () => {
const api = stubExportApi();
const records = [
chatRecord({ uuid: 'rec-1', timestamp: '2026-01-02T03:04:05.000Z' }),
chatRecord({ uuid: 'rec-2', timestamp: '2026-01-02T02:00:00.000Z' }),
];
const html = await renderHtmlFromObjects(records, api);
expect(html).toBe('<html>rendered</html>');
// startTime is the earliest record, not the first one in file order.
expect(api.collectSessionMetadata).toHaveBeenCalledWith(
expect.objectContaining({
sessionId: 'sess-1',
startTime: '2026-01-02T02:00:00.000Z',
messages: records,
}),
expect.anything(),
);
const [sessionData, passedRecords] = api.toHtml.mock.calls[0];
expect(passedRecords).toEqual(records);
expect(sessionData).toEqual(
expect.objectContaining({
sessionId: 'sess-1',
messages: [],
metadata: { sessionId: 'sess-1', messageCount: 2 },
}),
);
});
it('never reaches the renderer for legacy exported JSONL', async () => {
const api = stubExportApi();
await expect(
renderHtmlFromObjects([legacyMetadata()], api),
).rejects.toThrow(LEGACY_REJECTION);
expect(api.collectSessionMetadata).not.toHaveBeenCalled();
expect(api.toHtml).not.toHaveBeenCalled();
});
});
describe('looksLikeExportJsonl', () => {
it('matches only the legacy metadata header shape', () => {
expect(looksLikeExportJsonl([legacyMetadata()])).toBe(true);
expect(looksLikeExportJsonl([chatRecord()])).toBe(false);
expect(looksLikeExportJsonl([legacyMetadata({ type: 'user' })])).toBe(
false,
);
expect(
looksLikeExportJsonl([legacyMetadata({ startTime: undefined })]),
).toBe(false);
});
});
describe('looksLikeChatRecord', () => {
it('requires every field the renderer reads', () => {
expect(looksLikeChatRecord(chatRecord())).toBe(true);
expect(looksLikeChatRecord(null)).toBe(false);
// parentUuid may be null, but the key has to be present.
const { parentUuid: _dropped, ...withoutParent } = chatRecord();
expect(looksLikeChatRecord(withoutParent)).toBe(false);
expect(looksLikeChatRecord(chatRecord({ sessionId: 7 }))).toBe(false);
});
});
describe('readJsonlObjects', () => {
it('parses one object per line and skips blank lines', async () => {
const file = writeJsonl([
JSON.stringify(chatRecord({ uuid: 'rec-1' })),
'',
JSON.stringify(chatRecord({ uuid: 'rec-2' })),
'',
]);
const objects = await readJsonlObjects(file);
expect(objects.map((o) => o.uuid)).toEqual(['rec-1', 'rec-2']);
});
it('names the offending line when JSON is invalid', async () => {
const file = writeJsonl([JSON.stringify(chatRecord()), '{ not json']);
await expect(readJsonlObjects(file)).rejects.toThrow(
/Invalid JSONL line[\s\S]*\{ not json/,
);
});
});