mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-30 11:24:36 +00:00
* ci(serve): daemon A/B before/after preview on response-surface PRs The daemon analog of the web-shell visual before/after bot. For PRs that touch the qwen serve response surface, build the CLI from BOTH the PR base (main) and head, drive a fixed set of endpoints against each daemon, and diff the JSON responses into a before/after field table posted on the PR. A PR with no response change -> "no change". - serve-ab-diff.mjs: structural JSON diff (set-aware arrays, volatile- field masking) -> before/after table; buildComment assembles all scenarios. Pure helpers unit-tested (12 tests). - serve-ab-drive.mjs: boots the built daemon, drives GET /health, /health?deep=1, /capabilities (no model -- dummy creds), captures JSON. - serve-ab.yml / serve-ab-publish.yml: capture (untrusted, no secrets) + privileged workflow_run publisher (validate+bind PR, dedup, TOCTOU), mirroring the web-shell split. Verified locally: real daemon boot + capture, the engine diffing real capabilities -> the before/after table, and no-change detection. The workflow wiring (2x build + drive in CI) is exercised on the next serve PR. * ci(serve): address before/after review — merge-base, base fail-safe, CI-wire tests Applies the transferable web-shell before/after review findings to the daemon A/B: - Diff against the MERGE-BASE, not the base-branch tip, so a PR branch behind main doesn't show others' already-landed daemon changes reversed as this PR's diff. - continue-on-error on the base checkout so a flaky base degrades to after-only instead of sinking the job (the base build/drive already was). - Wire serve-ab-diff.test.mjs into the github_ci_only test step — it uses only node builtins, so it runs there dependency-free (no npm ci needed). * feat(serve): add a create-session deep-health A/B scenario Add a `health-deep-with-session` scenario: the drive harness gains setup-request support, creates one session (POST /session), then probes GET /health?deep=1 — exercising the session lifecycle + cross-workspace session aggregation (#6961's exact case). Broaden the volatile mask to cover lastActivityAt/idleSinceMs/sessionId/clientId/workspaceCwd so the capture is deterministic, while the meaningful counts (sessions, activePrompts, pendingPermissions, connectedClients, channelAlive) stay diffable. Verified against the real daemon: two runs of the same build diff to "no change" (deterministic), and a sessions 1->2 change surfaces as a before/after table row. * ci(serve): address review — narrow trigger, degraded-base marker, test the entrypoint - Narrow the trigger from packages/core/src/** (~1100 mostly-unrelated files) to packages/cli/src/serve/** — the daemon's actual response surface (core has no serve/health subtree). (Suggestion) - diffCaptureDirs signals baselineMissing when head captures exist but the base build/drive produced none, so the comment says "diff skipped" rather than misreporting every field as added or "no change". (Suggestion) - Export + test diffCaptureDirs (the function the CI `comment` subcommand actually invokes) with temp-fixture dirs, covering the diff + degraded path. (Suggestion) - Wording: "PR base" not "main" (the workflow also runs for release/**). * ci(serve): address review round 2 — escape table values, guard setup/baseline/daemon - fmt escapes `|` and backtick so an arbitrary daemon value can't split a GFM table cell or close the code span. - diffCaptureDirs surfaces base-only (removed/failed-to-capture) scenarios instead of silently dropping them — otherwise a broken scenario lowers the "across N" count and masks the regression. - The drive checks each setup request's status and throws on non-2xx, so a failed POST /session can't let health-deep-with-session capture wrong state (0 sessions) and fake/mask a diff. - The drive awaits daemon exit after SIGTERM (SIGKILL after 5s) so a hung daemon can't linger. * ci(serve): clear-error JSON parse for both capture sides The after-side JSON.parse was unguarded while the before-side wasn't; a readJson helper now gives a 'invalid JSON capture at <path>' error for either side instead of a raw SyntaxError. * ci(serve): review round 3 — merge-base skip, surface malformed base, escape path, kill dead stdout - serve-ab.yml: retry the merge-base compare then SKIP the before build (after-only) instead of falling back to the base-branch tip; gate the base checkout/build on a resolved sha + successful checkout. - diffCaptureDirs: an existing-but-malformed base capture now surfaces (existsSync + readJson) instead of a bare catch treating it as {} (which reported every field as "added"); covered by a new test. - renderTable: escape backticks in the path field too (fmt already did for values). - Drop the dead stdout + its misleading "workflow reads it" comment (the publisher posts whenever body.md exists); log the count to stderr. * ci(serve): renderTable label 'PR base' not 'main' (release/** provenance) buildComment already said 'PR base', but the per-scenario table header + no-change line still said 'main'; align them (the workflow also runs for release/**, whose base is not main). --------- Co-authored-by: wenshao <wenshao@example.com>
232 lines
8.4 KiB
JavaScript
232 lines
8.4 KiB
JavaScript
/**
|
|
* @license
|
|
* Copyright 2025 Qwen
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import assert from 'node:assert/strict';
|
|
import { mkdtempSync, writeFileSync } from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import test from 'node:test';
|
|
|
|
import {
|
|
buildComment,
|
|
diffCaptureDirs,
|
|
diffJson,
|
|
isPrimitiveArray,
|
|
maskPath,
|
|
renderTable,
|
|
typeOf,
|
|
} from './serve-ab-diff.mjs';
|
|
|
|
test('typeOf distinguishes null, array, object, primitive', () => {
|
|
assert.equal(typeOf(null), 'null');
|
|
assert.equal(typeOf([1]), 'array');
|
|
assert.equal(typeOf({}), 'object');
|
|
assert.equal(typeOf('x'), 'string');
|
|
assert.equal(typeOf(3), 'number');
|
|
});
|
|
|
|
test('isPrimitiveArray is true only for arrays of non-objects', () => {
|
|
assert.equal(isPrimitiveArray(['a', 'b']), true);
|
|
assert.equal(isPrimitiveArray([1, null, 'x']), true);
|
|
assert.equal(isPrimitiveArray([{ a: 1 }]), false);
|
|
assert.equal(isPrimitiveArray('nope'), false);
|
|
});
|
|
|
|
test('diffJson: identical objects → no changes (backend PR with no impact)', () => {
|
|
const o = { status: 'ok', features: ['a', 'b'], v: 1 };
|
|
assert.deepEqual(diffJson(o, JSON.parse(JSON.stringify(o))), []);
|
|
});
|
|
|
|
test('diffJson: added / removed / changed leaf fields', () => {
|
|
const changes = diffJson({ a: 1, gone: true }, { a: 2, added: 'x' });
|
|
assert.deepEqual(
|
|
changes.sort((x, y) => x.path.localeCompare(y.path)),
|
|
[
|
|
{ path: 'a', kind: 'changed', before: 1, after: 2 },
|
|
{ path: 'added', kind: 'added', after: 'x' },
|
|
{ path: 'gone', kind: 'removed', before: true },
|
|
],
|
|
);
|
|
});
|
|
|
|
test('diffJson: primitive arrays diff as SETS (one add, not an index shuffle)', () => {
|
|
const changes = diffJson(
|
|
{ features: ['a', 'b'] },
|
|
{ features: ['a', 'b', 'c'] },
|
|
);
|
|
assert.deepEqual(changes, [
|
|
{ path: 'features[]', kind: 'added', after: 'c' },
|
|
]);
|
|
// A removal:
|
|
assert.deepEqual(diffJson({ f: ['a', 'b'] }, { f: ['a'] }), [
|
|
{ path: 'f[]', kind: 'removed', before: 'b' },
|
|
]);
|
|
});
|
|
|
|
test('diffJson: object arrays diff by index; nested paths are dotted', () => {
|
|
const changes = diffJson(
|
|
{ items: [{ id: 1, n: 'a' }] },
|
|
{ items: [{ id: 1, n: 'b' }] },
|
|
);
|
|
assert.deepEqual(changes, [
|
|
{ path: 'items[0].n', kind: 'changed', before: 'a', after: 'b' },
|
|
]);
|
|
});
|
|
|
|
test('diffJson: a type change (array → object) is one "changed"', () => {
|
|
assert.deepEqual(diffJson({ x: [] }, { x: {} }), [
|
|
{ path: 'x', kind: 'changed', before: [], after: {} },
|
|
]);
|
|
});
|
|
|
|
test('maskPath / diffJson skips volatile fields (timestamps, pid, uptime …)', () => {
|
|
assert.equal(maskPath('daemon.uptimeMs'), true);
|
|
assert.equal(maskPath('startedAt'), true);
|
|
// Session-lifecycle volatiles (from the create-session scenario).
|
|
assert.equal(maskPath('lastActivityAt'), true);
|
|
assert.equal(maskPath('idleSinceMs'), true);
|
|
assert.equal(maskPath('sessionId'), true);
|
|
assert.equal(maskPath('workspaceCwd'), true);
|
|
// …but the meaningful counts next to them are NOT masked.
|
|
assert.equal(maskPath('sessions'), false); // not `sessionId`
|
|
assert.equal(maskPath('activePrompts'), false);
|
|
assert.equal(maskPath('features'), false);
|
|
// A change only in a volatile field yields no diff.
|
|
assert.deepEqual(
|
|
diffJson({ status: 'ok', uptimeMs: 10 }, { status: 'ok', uptimeMs: 999 }),
|
|
[],
|
|
);
|
|
});
|
|
|
|
test('renderTable: change table has a row per field, escapes paths in code spans', () => {
|
|
const md = renderTable('capabilities', [
|
|
{ path: 'features[]', kind: 'added', after: 'sse' },
|
|
{
|
|
path: 'qwenCodeVersion',
|
|
kind: 'changed',
|
|
before: '0.19.9',
|
|
after: '0.19.10',
|
|
},
|
|
]);
|
|
assert.match(md, /#### `capabilities`/);
|
|
assert.match(md, /\| `features\[\]` \| — \| `"sse"` \|/);
|
|
assert.match(md, /\| `qwenCodeVersion` \| `"0.19.9"` \| `"0.19.10"` \|/);
|
|
});
|
|
|
|
test('renderTable: empty diff → explicit no-change note (not a blank table)', () => {
|
|
const md = renderTable('health', []);
|
|
assert.match(md, /No response change vs the PR base/);
|
|
assert.doesNotMatch(md, /\| field \|/);
|
|
});
|
|
|
|
test('buildComment: only changed scenarios get a table; unchanged omitted', () => {
|
|
const body = buildComment(
|
|
[
|
|
{
|
|
scenario: 'capabilities',
|
|
changes: [{ path: 'features[]', kind: 'added', after: 'sse' }],
|
|
},
|
|
{ scenario: 'health', changes: [] },
|
|
],
|
|
{ shortSha: 'abc1234' },
|
|
);
|
|
assert.match(body, /<!-- qwen:serve-ab -->/);
|
|
assert.match(body, /serve daemon A\/B/);
|
|
assert.match(body, /abc1234/);
|
|
assert.match(body, /#### `capabilities`/);
|
|
assert.doesNotMatch(body, /#### `health`/); // unchanged scenario omitted
|
|
});
|
|
|
|
test('buildComment: all unchanged → one no-change note, no tables', () => {
|
|
const body = buildComment(
|
|
[
|
|
{ scenario: 'health', changes: [] },
|
|
{ scenario: 'capabilities', changes: [] },
|
|
],
|
|
{ shortSha: 'deadbee' },
|
|
);
|
|
assert.match(
|
|
body,
|
|
/No response changes against the PR base across 2 scenario/,
|
|
);
|
|
assert.doesNotMatch(body, /\| field \|/);
|
|
});
|
|
|
|
test('diffCaptureDirs: diffs each scenario against its same-named base file', () => {
|
|
const before = mkdtempSync(join(tmpdir(), 'sa-before-'));
|
|
const after = mkdtempSync(join(tmpdir(), 'sa-after-'));
|
|
writeFileSync(
|
|
join(before, 'capabilities.json'),
|
|
JSON.stringify({ v: 1, features: ['a'] }),
|
|
);
|
|
writeFileSync(
|
|
join(after, 'capabilities.json'),
|
|
JSON.stringify({ v: 1, features: ['a', 'b'] }),
|
|
);
|
|
writeFileSync(join(before, 'health.json'), JSON.stringify({ status: 'ok' }));
|
|
writeFileSync(join(after, 'health.json'), JSON.stringify({ status: 'ok' }));
|
|
const { sections, baselineMissing } = diffCaptureDirs(before, after);
|
|
assert.equal(baselineMissing, false);
|
|
assert.deepEqual(sections.map((s) => s.scenario).sort(), [
|
|
'capabilities',
|
|
'health',
|
|
]);
|
|
assert.deepEqual(
|
|
sections.find((s) => s.scenario === 'capabilities').changes,
|
|
[{ path: 'features[]', kind: 'added', after: 'b' }],
|
|
);
|
|
assert.deepEqual(sections.find((s) => s.scenario === 'health').changes, []);
|
|
});
|
|
|
|
test('diffCaptureDirs: absent base + present head → baselineMissing (not "no change")', () => {
|
|
const before = mkdtempSync(join(tmpdir(), 'sa-before-')); // left empty
|
|
const after = mkdtempSync(join(tmpdir(), 'sa-after-'));
|
|
writeFileSync(join(after, 'capabilities.json'), JSON.stringify({ v: 1 }));
|
|
const { baselineMissing } = diffCaptureDirs(before, after);
|
|
assert.equal(baselineMissing, true);
|
|
const body = buildComment([], { shortSha: 'x', baselineMissing: true });
|
|
assert.match(body, /baseline could not be built/);
|
|
assert.doesNotMatch(body, /No response changes/);
|
|
});
|
|
|
|
test('renderTable: escapes pipes + backticks so a value cannot break the table', () => {
|
|
const md = renderTable('x', [
|
|
{ path: 'note', kind: 'changed', before: 'a|b', after: '`c`' },
|
|
]);
|
|
assert.match(md, /a\\\|b/); // pipe backslash-escaped
|
|
assert.match(md, /`c`/); // backtick entity-encoded
|
|
const row = md.split('\n').find((l) => l.includes('`note`'));
|
|
// Exactly the 4 cell separators — the escaped `\|` is not counted.
|
|
assert.equal((row.match(/(?<!\\)\|/g) || []).length, 4);
|
|
});
|
|
|
|
test('diffCaptureDirs: a malformed capture surfaces a clear error (not silent {})', () => {
|
|
const before = mkdtempSync(join(tmpdir(), 'sa-before-'));
|
|
const after = mkdtempSync(join(tmpdir(), 'sa-after-'));
|
|
// Malformed HEAD capture → clear error, not a raw SyntaxError.
|
|
writeFileSync(join(after, 'health.json'), '{ not valid json');
|
|
assert.throws(() => diffCaptureDirs(before, after), /invalid JSON capture/);
|
|
// Existing-but-malformed BASE capture → also surfaces (not silently {} →
|
|
// "everything added").
|
|
writeFileSync(join(after, 'health.json'), JSON.stringify({ status: 'ok' }));
|
|
writeFileSync(join(before, 'health.json'), 'garbage');
|
|
assert.throws(() => diffCaptureDirs(before, after), /invalid JSON capture/);
|
|
});
|
|
|
|
test('diffCaptureDirs: a base-only (removed) scenario is surfaced, not dropped', () => {
|
|
const before = mkdtempSync(join(tmpdir(), 'sa-before-'));
|
|
const after = mkdtempSync(join(tmpdir(), 'sa-after-'));
|
|
writeFileSync(join(before, 'health.json'), JSON.stringify({ status: 'ok' }));
|
|
writeFileSync(join(after, 'health.json'), JSON.stringify({ status: 'ok' }));
|
|
writeFileSync(join(before, 'capabilities.json'), JSON.stringify({ v: 1 })); // gone in head
|
|
const { removed } = diffCaptureDirs(before, after);
|
|
assert.deepEqual(removed, ['capabilities']);
|
|
const body = buildComment([], { shortSha: 'x', removed });
|
|
assert.match(
|
|
body,
|
|
/Present in the base but absent from this PR: `capabilities`/,
|
|
);
|
|
});
|