qwen-code/.github/scripts/serve-ab-diff.test.mjs
Shaojin Wen 51026d4d06
ci(serve): daemon A/B before/after preview on response-surface PRs (#6975)
* 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>
2026-07-16 00:58:52 +00:00

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, /&#96;c&#96;/); // 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`/,
);
});