Files
qwen-code/scripts/tests/e2e-workflow.test.js
T
ChiGao 6e6598e35c fix(cli): restore bounded OpenTUI dialogs and add migration gates (#14933)
* fix(cli): restore bounded OpenTUI dialogs and add migration gates

* fix(tui): harden migration cleanup and evidence after review

* test(tui): fail acceptance on unhandled errors
2026-10-21 10:05:14 +08:00

736 lines
33 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { existsSync, readFileSync, readdirSync } from 'node:fs';
import { describe, expect, it } from 'vitest';
import { parse } from 'yaml';
describe('TUI product evidence', () => {
const workflow = parse(
readFileSync('.github/workflows/tui-parity.yml', 'utf8'),
);
const steps = workflow.jobs.parity.steps;
it('treats unhandled errors as acceptance failures on every runner', () => {
const product = steps.find((step) =>
step.name?.startsWith('Three-leg product acceptance'),
);
const commands = product.run
.split('\n')
.filter((line) => line.includes('npx vitest run'));
expect(commands).toHaveLength(2);
for (const command of commands) {
expect(command).toContain('--dangerouslyIgnoreUnhandledErrors=false');
}
});
it('keeps fresh evidence retrievable when the same head is rerun', () => {
const upload = steps.find(
(step) => step.name === 'Upload product acceptance evidence',
);
const product = steps.find((step) =>
step.name?.startsWith('Three-leg product acceptance'),
);
expect(upload.if).toContain('always()');
expect(upload.with.name).toContain('github.event.pull_request.head.sha');
expect(upload.with.name).toContain('${{ github.run_attempt }}');
const artifactName = (attempt) =>
upload.with.name.replace('${{ github.run_attempt }}', String(attempt));
expect(artifactName(1)).not.toBe(artifactName(2));
expect(upload.with.path.replace(/\/$/, '')).toBe(
product.env.QWEN_TUI_ACCEPTANCE_OUT,
);
});
});
describe('e2e workflow', () => {
const workflow = readFileSync('.github/workflows/e2e.yml', 'utf8');
const e2eRunScript = readFileSync('.github/scripts/run-e2e-tests.sh', 'utf8');
const buildSandboxScript = readFileSync('scripts/build_sandbox.js', 'utf8');
const yml = parse(workflow);
it('never cancels in-progress runs on main', () => {
// A full run takes ~40min while merges land every ~18min, so cancelling on
// every merge starved the suite — over 100 push runs, 67 were cancelled and
// only 25 ever reported. Runs on main must finish; dev branches still cancel
// superseded runs. A future simplification back to `event_name == 'push'`
// would silently reintroduce the starvation, so the guard is asserted.
const cancel = yml.concurrency['cancel-in-progress'];
expect(cancel).toContain(
"github.event_name == 'push' && github.ref_name != 'main'",
);
});
it('scopes the concurrency group by event and ref', () => {
// Scoping by event keeps main pushes coalescing with each other without
// touching the nightly schedule or a manual dispatch on the same ref.
const group = yml.concurrency.group;
expect(group).toContain('github.workflow');
expect(group).toContain('github.event_name');
expect(group).toContain('github.head_ref || github.ref_name');
});
it('isolates child-process-heavy suites from the three parallel forks', () => {
const linuxJob = yml.jobs['e2e-test-linux'];
const runStep = linuxJob.steps.find(
(step) => step.name === 'Run E2E tests',
);
expect(linuxJob.strategy.matrix.shard).toEqual(['1/1']);
expect(runStep.run).toContain('.github/scripts/run-e2e-tests.sh');
expect(e2eRunScript).toContain(
'npx cross-env QWEN_E2E_RENDERER=ink QWEN_SANDBOX=docker vitest run --root ./integration-tests "$@"',
);
expect(e2eRunScript).toContain(
'QWEN_E2E_RENDERER=ink npm run test:integration:sandbox:none -- "$@"',
);
expect(
e2eRunScript.match(/--exclude '\*\*\/qwen-serve-routes\.test\.ts'/g),
).toHaveLength(1);
expect(
e2eRunScript.match(/--exclude '\*\*\/sdk-typescript\/\*\*'/g),
).toHaveLength(1);
expect(e2eRunScript).toContain(
'run_vitest sdk-typescript cli/qwen-serve-routes.test.ts --poolOptions.forks.maxForks=1',
);
expect(e2eRunScript).not.toContain('--poolOptions.forks.singleFork');
// The arrangement is load-bearing: the batch must run first and the
// isolated suites second, joined by `&&` with no other command between
// or after. Counts and substrings are order- and backgrounding-blind;
// the pattern tolerates formatting-only rewrites (continuations, comment
// lines, trailing spaces) so a benign edit cannot turn CI red.
expect(e2eRunScript).toMatch(
/run_vitest "\$\{bulk_args\[@\]\}"[ \t]*&&[ \t]*\\?\n?(?:[ \t]*#[^\n]*\n)*[ \t]*run_vitest sdk-typescript cli\/qwen-serve-routes\.test\.ts --poolOptions\.forks\.maxForks=1[ \t]*\n\}/,
);
// Text pins never touch the tree: renaming the file or directory these
// filters name silently returns the suites to the three-fork batch.
expect(existsSync('integration-tests/cli/qwen-serve-routes.test.ts')).toBe(
true,
);
expect(existsSync('integration-tests/sdk-typescript')).toBe(true);
expect(existsSync('integration-tests/channel-plugin.test.ts')).toBe(true);
expect(
existsSync('integration-tests/chat-transcript-document.test.ts'),
).toBe(true);
expect(
existsSync('integration-tests/interactive/cron-interactive.test.ts'),
).toBe(true);
// existsSync pins the directory's name, not its contents: suites moved
// out of it would rejoin the three-fork batch through the dead exclude,
// while vitest silently ignores a positional filter that matches nothing
// as long as a sibling filter still matches.
expect(
readdirSync('integration-tests/sdk-typescript').some((file) =>
file.endsWith('.test.ts'),
),
).toBe(true);
});
describe('sandbox image preparation', () => {
const steps = yml.jobs['e2e-test-linux'].steps;
const setupStep = steps.find((step) => step.name === 'Set up Docker');
const runStep = steps.find((step) => step.name === 'Run E2E tests');
it('does not create one Buildx builder per self-hosted shard', () => {
expect(setupStep.if).toContain("runner.environment == 'github-hosted'");
});
it('serializes image preparation on the shared Docker host', () => {
expect(e2eRunScript).toContain(
'docker-sandbox-build-e2e-${GITHUB_SHA}.lock',
);
expect(e2eRunScript).toContain('flock --wait 1800 8');
expect(e2eRunScript).toContain(
'exec 9>"${HOME}/.cache/qwen-code-ci/docker-sandbox-daemon.lock"',
);
expect(e2eRunScript).toContain('flock --shared --wait 1800 9');
expect(e2eRunScript).toContain(
'exec 7>"${HOME}/.cache/qwen-code-ci/docker-sandbox-build.lock"',
);
expect(e2eRunScript).toContain('flock --wait 1800 7');
expect(e2eRunScript).toContain(
'if [ "$RUNNER_ENVIRONMENT" = \'self-hosted\' ]',
);
});
it('limits image cleanup to less than the build-lock wait', () => {
const pruneMatch = e2eRunScript
.replace(/\\\r?\n/g, '')
.match(
/^[ \t]*timeout[ \t]+(?:(?:-[ks][ \t]*|--(?:kill-after|signal)(?:=|[ \t]+))\S+[ \t]+)*(\d+)([smhd]?)[ \t]+docker[ \t]+image[ \t]+prune\b/m,
);
expect(
pruneMatch,
'Expected a timeout on docker image prune',
).not.toBeNull();
const unitSeconds = { s: 1, m: 60, h: 3600, d: 86400 };
const pruneSeconds =
Number(pruneMatch[1]) * unitSeconds[pruneMatch[2] || 's'];
const lockWaitSeconds = Number(
e2eRunScript.match(/flock --wait (\d+) 7/)?.[1],
);
expect(pruneSeconds).toBeGreaterThan(0);
expect(pruneSeconds).toBeLessThan(lockWaitSeconds);
});
it('reuses a commit-qualified image', () => {
expect(runStep.env.BUILD_SANDBOX_FLAGS).toContain(
'org.qwen-code.ci.sandbox=true',
);
expect(e2eRunScript).toContain('sandboxImageUri")-e2e-${GITHUB_SHA}"');
expect(e2eRunScript).toContain('docker image inspect "$sandbox_image"');
});
it('pins each shard to the prepared image ID', () => {
expect(e2eRunScript).toContain("docker image inspect --format '{{.Id}}'");
expect(e2eRunScript).toContain(
'export QWEN_SANDBOX_IMAGE="$sandbox_image_id"',
);
});
it('keeps one bounded retry without pruning the shared daemon', () => {
expect(e2eRunScript.match(/build_image/g)).toHaveLength(3);
expect(e2eRunScript).toContain(
'npm run build:sandbox -- -s --no-prune -i "$sandbox_image"',
);
expect(buildSandboxScript).toContain(".option('prune'");
expect(buildSandboxScript).toContain('if (argv.prune)');
});
it('keeps the Docker build environment', () => {
expect(runStep.env.QWEN_SANDBOX).toContain("'docker'");
expect(runStep.env.VERBOSE).toBe('true');
});
it('reaps only the sandbox containers owned by its matrix job', () => {
const owner =
'${{ github.run_id }}-${{ github.run_attempt }}-${{ matrix.shard }}';
const cleanupStep = steps.find(
(step) => step.name === 'Remove job-owned E2E containers',
);
expect(yml.jobs['e2e-test-linux'].env.E2E_CONTAINER_OWNER).toBe(owner);
expect(runStep.env.SANDBOX_FLAGS).toContain(
'org.qwen-code.ci.owner=${E2E_CONTAINER_OWNER}',
);
expect(e2eRunScript).toContain('trap cleanup_e2e_job EXIT');
expect(e2eRunScript).toContain("trap 'exit 1' INT TERM");
expect(e2eRunScript).toContain(
'--filter "label=org.qwen-code.ci.owner=${E2E_CONTAINER_OWNER}"',
);
expect(cleanupStep.if).toContain('always()');
expect(cleanupStep.run).toContain(
'--filter "label=org.qwen-code.ci.owner=${E2E_CONTAINER_OWNER}"',
);
expect(cleanupStep.run).toContain('docker rm -f > /dev/null || true');
expect(cleanupStep.run.match(/docker ps -aq/g)).toHaveLength(2);
expect(cleanupStep.run).toContain('E2E containers remain');
});
it('never waits on a lock another run holds through its tests', () => {
// Run 33637097713 lost two Docker shards to the #10605 protocol on one
// host: shard 1/3 held the per-commit coordinator lock and polled 30
// minutes for an exclusive daemon lock that a shard of run 33638984513
// kept shared through its whole test phase, then shard 2/3 timed out
// behind the coordinator lock shard 1/3 was still holding. Image
// preparation may only ever wait on locks bounded by a build.
const sharedIndex = e2eRunScript.indexOf('flock --shared --wait 1800 9');
const buildLockIndex = e2eRunScript.indexOf('flock --wait 1800 7');
const releaseIndex = e2eRunScript.indexOf('flock --unlock 7');
const testIndex = e2eRunScript.indexOf('vitest run');
expect(sharedIndex).toBeGreaterThanOrEqual(0);
expect(buildLockIndex).toBeGreaterThan(sharedIndex);
expect(releaseIndex).toBeGreaterThan(buildLockIndex);
expect(testIndex).toBeGreaterThan(releaseIndex);
expect(e2eRunScript).not.toContain('acquire_daemon_write_lock');
expect(e2eRunScript).not.toContain('flock --unlock 9');
expect(e2eRunScript).not.toContain('flock --nonblock 9');
expect(
yml.jobs['e2e-test-linux'].strategy['max-parallel'],
).toBeUndefined();
});
it('closes the lock descriptors in every child process', () => {
// A flock lives on the open file description, so a descendant that
// inherits the descriptor and outlives its job keeps holding the lock
// on the host. This shell keeps its own copy of the descriptor, so
// closing it in children costs nothing.
expect(e2eRunScript).toContain('-i "$sandbox_image" 7>&- 8>&- 9>&-');
expect(e2eRunScript).toContain(
'vitest run --root ./integration-tests "$@" 9>&-',
);
expect(e2eRunScript).toContain('exec 7>&-');
expect(e2eRunScript).toContain('exec 8>&-');
const cleanupStep = steps.find(
(step) => step.name === 'Prune dangling docker images',
);
expect(cleanupStep.run).toContain("--filter 'until=24h' 9>&-");
});
});
describe('sandbox:none shard retry', () => {
// Runs 33293739505, 33302550436 and 33317457036 each failed the
// sandbox:none leg at the 'Run E2E tests' step with zero vitest FAIL
// lines — an all-green shard exiting red under shared-host pressure,
// with sibling shards of the same runs green and the shard green on
// re-run. The bounded retry absorbs one such transient death; a
// deterministic test failure fails both attempts and keeps the job red.
const steps = yml.jobs['e2e-test-linux'].steps;
const runStep = steps.find((step) => step.name === 'Run E2E tests');
const epochStep = steps.find(
(step) => step.name === 'Record job start epoch',
);
it('records the job start epoch before the expensive setup steps', () => {
// The retry gate budgets against the whole 60-minute job; an epoch
// recorded at the test step would hide ~30 minutes of setup spend.
expect(epochStep.run).toContain(
'echo "E2E_JOB_START_EPOCH=$(date +%s)" >> "${GITHUB_ENV}"',
);
expect(steps.indexOf(epochStep)).toBeLessThan(
steps.indexOf(
steps.find((step) => step.name === 'Install dependencies'),
),
);
// An `if:` here would skip the record on one leg, where the gate's
// ${E2E_JOB_START_EPOCH:-0} fallback then always takes the ::error::
// branch and the retry never fires on the leg it exists for.
expect(epochStep.if).toBeUndefined();
});
it('wraps the sandbox:none shard command in a retryable function', () => {
expect(e2eRunScript).toContain('run_shard() {');
});
it('retries the full shard command, shard and excludes included', () => {
expect(e2eRunScript).toContain('run_vitest "${bulk_args[@]}"');
expect(e2eRunScript).toContain('--poolOptions.forks.maxForks=3');
expect(e2eRunScript).toContain('--shard="$shard"');
});
it('retries the sandbox:none shard exactly once', () => {
expect(e2eRunScript).toContain('run_shard || {');
// Definition + first attempt + one retry: the second attempt's exit
// status is the step's, and a third attempt would burn pool time for
// nothing.
const retryBranch = e2eRunScript.slice(
e2eRunScript.lastIndexOf('if [ "$sandbox" = \'sandbox:docker\' ]'),
);
expect(retryBranch.match(/run_shard/g)).toHaveLength(3);
// End-anchored scope: the retry is the group's last command and the
// group is the script's last statement. A retry moved outside the
// `|| { ... }` would run unconditionally, re-running green shards too.
expect(e2eRunScript).toMatch(/run_shard\s*\n\s*\}\s*\n\s*fi\s*$/);
});
it('gates the retry on the remaining job budget', () => {
// The retried run_shard is reachable only behind an elapsed-time check
// that exits the step when the job cannot fit another shard. Shape
// only — bash itself witnesses the execution semantics in
// e2e-shard-retry.test.js.
const group = e2eRunScript.slice(e2eRunScript.indexOf('run_shard || {'));
expect(group).toMatch(/elapsed[\s\S]*exit 1[\s\S]*run_shard\s*\n\s*\}/);
});
it('pins the job timeout the budget-gate arithmetic is built on', () => {
// The 2100s threshold is 3600s minus a 25-minute reserve; the 3600s
// comes from this timeout. Editing one without the other mis-budgets
// the retry in both directions with every other witness green.
expect(yml.jobs['e2e-test-linux']['timeout-minutes']).toBe(60);
});
it('keeps the run step red when the shard stays red', () => {
// continue-on-error sits above the script exit code that every other
// witness observes: with it, two failing attempts still report green.
// Pin both levels — a job-level key computes the job conclusion green
// whatever the run step exits. The sandbox-image build step's
// deliberate step-level key and isolated-nightly's deliberate job-level
// key stay untouched — this pins the run step and e2e-test-linux only.
expect(runStep['continue-on-error']).toBeUndefined();
expect(yml.jobs['e2e-test-linux']['continue-on-error']).toBeUndefined();
});
it('passes the matrix sandbox and shard to the runner script', () => {
expect(runStep.run).toBe(
"exec bash .github/scripts/run-e2e-tests.sh '${{ matrix.sandbox }}' '${{ matrix.shard }}'",
);
});
it('does not retry the docker leg', () => {
const dockerBranch = e2eRunScript
.slice(
e2eRunScript.lastIndexOf('if [ "$sandbox" = \'sandbox:docker\' ]'),
)
.split('\nelse\n', 1)[0];
expect(dockerBranch.match(/run_shard/g)).toHaveLength(1);
});
});
describe('docker lock cache ownership heal', () => {
// Run 35054004633 (#11990) failed exactly the two steps that open
// ~/.cache/qwen-code-ci/docker-sandbox-daemon.lock — 'Run E2E tests'
// (via run-e2e-tests.sh) and 'Prune dangling docker images' — while
// the container-runtime preflight passed. Run 35039618620 (#11973)
// showed why: `exec 9>` on that lock fails with EACCES when a
// root-owned leftover sits in the runner's home, and both steps die
// on the same file. The workspace heal only chowns $GITHUB_WORKSPACE;
// the lock directory lives outside it, so the heal must reach it too.
const steps = yml.jobs['e2e-test-linux'].steps;
const heal = steps.find(
(step) => step.name === 'Restore workspace ownership',
);
it('chowns the host-side docker lock directory back to the runner', () => {
expect(heal).toBeDefined();
expect(heal.run).toContain('[ -d "${HOME}/.cache/qwen-code-ci" ]');
// The whole fallback chain as one contiguous pin: each half alone
// contains the short pin, so dropping `-R` from the unprivileged
// attempt or dropping the `sudo -n` fallback leaves a substring pin
// green while the heal dies on the pool.
expect(heal.run).toContain(
'chown -R "$RUNNER_UID:$RUNNER_GID" "${HOME}/.cache/qwen-code-ci" 2>/dev/null || sudo -n chown -R "$RUNNER_UID:$RUNNER_GID" "${HOME}/.cache/qwen-code-ci"',
);
});
it('heals before any step opens the daemon lock', () => {
const names = steps.map((step) => step.name);
expect(names.indexOf('Restore workspace ownership')).toBeLessThan(
names.indexOf('Run E2E tests'),
);
expect(names.indexOf('Restore workspace ownership')).toBeLessThan(
names.indexOf('Prune dangling docker images'),
);
});
});
describe('disk floor gate', () => {
// The E2E lane's slice of #10035 (#12764): run 36241138248 lost the
// sandbox:none leg when the saturated pool host's runner worker died on
// ENOSPC writing its own diag log — no failed step, no test result, one
// per-commit issue. ci.yml has gated its heavy jobs on
// check-disk-floor.sh since #10035 and the release lane since #11972;
// the pool-routed E2E leg went without. The gate fails the job fast
// with a DISKFLOOR sample naming the runner, and a re-run lands on an
// instance with headroom.
const steps = yml.jobs['e2e-test-linux'].steps;
const names = steps.map((step) => step.name);
const gateIndex = names.indexOf('Disk floor gate (self-hosted)');
it('gates the pool-routed Linux leg on a disk floor before its install', () => {
expect(gateIndex).toBeGreaterThanOrEqual(0);
const gate = steps[gateIndex];
expect(gate.if).toBe("${{ runner.environment == 'self-hosted' }}");
// The same direct call as ci.yml: this workflow only ever runs the
// pushed or current ref, so unlike the release lane the script always
// exists in the checkout and needs no presence guard.
expect(gate.run).toBe(
'bash .github/scripts/check-disk-floor.sh "${GITHUB_WORKSPACE}" "${RUNNER_TEMP:-/tmp}"',
);
const checkoutIndex = steps.findIndex((step) =>
String(step.uses ?? '').includes('actions/checkout'),
);
// The script rides the checkout, so the gate can only stand between
// it and the install that turns a near-full disk into a dead runner.
expect(gateIndex).toBeGreaterThan(checkoutIndex);
expect(gateIndex).toBeLessThan(names.indexOf('Install dependencies'));
});
it('keeps the gate on every pool-routed job', () => {
// A new pool-routed leg without the gate reddens here instead of on a
// saturated host; the hosted legs stay ungated on fresh VMs.
const poolJobs = Object.entries(yml.jobs).filter(([, job]) =>
String(job['runs-on'] ?? '').includes('ecs-qwen'),
);
expect(poolJobs.map(([id]) => id)).toEqual(['e2e-test-linux']);
for (const [id, job] of poolJobs) {
const gate = (job.steps ?? []).find(
(step) => step.name === 'Disk floor gate (self-hosted)',
);
expect(gate, id).toBeDefined();
}
});
});
describe('one build for every leg', () => {
// Each leg used to build and bundle on its own runner — 4–8 minutes on a
// hosted VM, 10–17 on a busy pool host, eleven times per run. The `build`
// job does it once on a hosted VM and the legs unpack its archive; these
// pins keep a leg from quietly growing its own build back.
const build = yml.jobs.build;
const legs = [
'e2e-test-linux',
'e2e-test-macos',
'e2e-interactive-opentui',
'isolated-nightly',
];
it('builds once, on a hosted runner, off the shared pool', () => {
expect(build.needs).toBeUndefined();
expect(build['runs-on']).toBe('ubuntu-latest');
const names = build.steps.map((step) => step.name);
expect(names).toContain('Build project');
expect(names).toContain('Bundle CLI for E2E tests');
const pack = build.steps.find(
(step) => step.name === 'Pack build outputs',
);
expect(pack.run).toContain('.github/scripts/e2e-build-pack.sh');
// The same "the install must not build" premise as on the legs: without
// it the install runs prepare (a full build and bundle) and the explicit
// build steps below then do it a second time on the critical path.
const install = build.steps.find(
(step) => step.name === 'Install dependencies',
);
expect(install.env.QWEN_SKIP_PREPARE).toBe('1');
const upload = build.steps.find(
(step) => step.name === 'Upload build artifact',
);
expect(upload.uses).toMatch(/^actions\/upload-artifact@/);
expect(upload.with.name).toBe('e2e-build');
expect(upload.with['retention-days']).toBe(1);
});
it('gates the build like the legs, so a skipped build skips them', () => {
// The three fork-gated legs carry the build's exact gate; the nightly
// legs are narrower (schedule/dispatch only), which is a subset.
for (const job of [
'e2e-test-linux',
'e2e-test-macos',
'e2e-interactive-opentui',
]) {
expect(yml.jobs[job].if, job).toBe(build.if);
}
// The whole expression, not a substring: the workflow declares no
// pull_request trigger, so the `event_name != 'pull_request' ||`
// prefix is the only clause that is ever true. Dropping it would skip
// the build — and every leg behind it — on every real event, green.
expect(build.if).toBe(
"${{ github.event_name != 'pull_request' || github.event.pull_request.head.repo.full_name == github.repository }}",
);
});
it('keeps the web-shell regression job building during its install', () => {
// That job has no build step of its own: its tree comes solely from
// the prepare script that the install runs, so it must not carry the skip
// the artifact-fed legs carry.
const install = yml.jobs['web-shell-browser-regression'].steps.find(
(step) => step.name === 'Install dependencies',
);
expect(install.env?.QWEN_SKIP_PREPARE).toBeUndefined();
});
it.each(legs)('%s unpacks the shared build instead of building', (job) => {
const { needs, steps } = yml.jobs[job];
expect(needs).toEqual(['build']);
const names = steps.map((step) => step.name);
expect(names).not.toContain('Build project');
expect(names).not.toContain('Bundle CLI for E2E tests');
const download = steps.find(
(step) => step.name === 'Download build artifact',
);
expect(download.with.name).toBe('e2e-build');
const unpack = steps.find(
(step) => step.name === 'Unpack build artifact',
);
expect(unpack.run).toContain('.github/scripts/e2e-build-unpack.sh');
// Unpack before anything runs the CLI, after node_modules exist.
expect(names.indexOf('Install dependencies')).toBeLessThan(
names.indexOf('Unpack build artifact'),
);
expect(names.indexOf('Unpack build artifact')).toBeLessThan(
names.findIndex((name) => name.startsWith('Run ')),
);
const install = steps.find(
(step) => step.name === 'Install dependencies',
);
expect(install.env.QWEN_SKIP_PREPARE).toBe('1');
});
it('keeps the docker sandbox image build on the leg', () => {
// The image builds inside Docker from the checkout, so it is not part
// of the archive; the leg still prepares it under the host locks.
expect(e2eRunScript).toContain('npm run build:sandbox');
});
});
describe('install retry', () => {
// Run 34700339334 died at the build job's bare `npm ci` before any test
// ran — the same install reproduces clean at that commit, so the failure
// was a transient the tree could not explain — and every leg behind
// `needs: [build]` went down with it. repo-hygiene.yml and
// qwen-autofix.yml already wrap their installs in this exact bounded
// retry; a regression to a bare unretried install is silent until the next
// transient reddens a main run, so pin the shape on every install step.
const installSteps = Object.entries(yml.jobs).flatMap(([jobName, job]) =>
(job.steps ?? [])
.filter((step) => step.name === 'Install dependencies')
.map((step) => [jobName, step]),
);
it('wraps every Install dependencies step in the bounded retry', () => {
// Six jobs install: the build, the three artifact-fed legs, the
// nightly legs, and the web-shell browser gate. A new job adding a
// bare install must fail here, not in a main-branch run.
expect(installSteps.map(([jobName]) => jobName).sort()).toEqual([
'build',
'e2e-interactive-opentui',
'e2e-test-linux',
'e2e-test-macos',
'isolated-nightly',
'web-shell-browser-regression',
]);
// Fragment pins, not a byte-exact body: a formatting-only rewrite or
// a post-install line appended after `done` must stay green (the repo
// pins this same recipe in qwen-autofix.yml by fragment), while
// dropping the loop, the backoff, or the failure exit still reddens.
for (const [jobName, step] of installSteps) {
expect(step.run, jobName).toContain('for attempt in 1 2 3; do');
expect(step.run, jobName).toContain(
'if corepack pnpm install --frozen-lockfile --prefer-offline --reporter=append-only; then',
);
expect(step.run, jobName).toContain('exit 1');
expect(step.run, jobName).toContain('sleep $((attempt * 15))');
expect(step.run, jobName).toContain('break');
expect(step.run, jobName).toContain(
'if [[ "${attempt}" == "3" ]]; then',
);
expect(step.run, jobName).toContain(
'if [[ "${attempt}" != "1" ]]; then',
);
// The ::warning:: keeps an absorbed install transient countable even
// though the recovered job concludes green — the same rule the
// upload-artifact retry's announce step follows. Deleting the echo
// from any one copy must red this loop.
expect(step.run, jobName).toContain(
'echo "::warning::corepack pnpm install',
);
// The defect under test is a bare `corepack pnpm install` line outside the loop.
expect(step.run, jobName).not.toMatch(/^\s*corepack pnpm install/m);
}
});
// Fail closed on the mention, not on a recognised spelling: the ways
// shell can write one command cannot be enumerated against a regex. The
// previous pair exempted a whole body for carrying any retry loop — a
// trailing bare install rode the exemption — and saw only installs
// opening their line, so `cd … && pnpm install` and `time pnpm install` were
// invisible. The exemption is keyed on the line's shape, never the
// step's name: a name key pardons the six pinned bodies wholesale, so a
// bare install appended after `done` would ride the step's identity
// with its body never read. Require every executable line mentioning
// `pnpm install` to open with one of the two retry-loop lines pinned above,
// so an unrecognised shape reddens the suite for a human to judge
// instead of passing silently. Full-line `#` comments never execute, so
// a body quoting the recipe in prose is excluded rather than flagged.
const retriedInstallLines = [
'if corepack pnpm install --frozen-lockfile --prefer-offline --reporter=append-only; then',
'echo "::warning::corepack pnpm install',
];
const findUnretriedInstalls = (jobs) =>
Object.entries(jobs).flatMap(([jobName, job]) =>
(job.steps ?? [])
.filter(
(step) =>
typeof step.run === 'string' &&
step.run
.split('\n')
.filter((line) => !line.trimStart().startsWith('#'))
.some(
(line) =>
line.includes('pnpm install') &&
!retriedInstallLines.some((ok) =>
line.trimStart().startsWith(ok),
),
),
)
.map((step) => `${jobName}/${step.name ?? '(unnamed)'}`),
);
it('retries every pnpm install run body, whatever the step is named', () => {
// The name-keyed collection above misses an install hiding under any
// other step name — repo-hygiene.yml and qwen-autofix.yml call theirs
// 'Install dependencies and build' — so scan the command itself.
expect(findUnretriedInstalls(yml.jobs)).toEqual([]);
});
it('flags an unretried install however shell spells it', () => {
// Each synthetic body slipped the old regex pair — the loop exempting
// a trailing bare install was the filed escape; the rest never open
// their install line. The real bodies ride along under their own keys
// to pin that the allowlist recognises exactly the two retried lines,
// and `build` is overridden by a copy carrying a bare install appended
// after `done` — the shape a name-keyed exemption pardons unread. A
// comment-only mention stays unflagged because it never executes.
const jobs = {
...Object.fromEntries(
installSteps.map(([jobName, step]) => [jobName, { steps: [step] }]),
),
build: {
steps: [
{
name: 'Install dependencies',
run: [
'for attempt in 1 2 3; do',
' if corepack pnpm install --frozen-lockfile --prefer-offline --reporter=append-only; then',
' if [[ "${attempt}" != "1" ]]; then',
' echo "::warning::corepack pnpm install failed $((attempt - 1)) time(s)"',
' fi',
' break',
' fi',
' if [[ "${attempt}" == "3" ]]; then',
' exit 1',
' fi',
' sleep $((attempt * 15))',
'done',
'cd integration-tests && pnpm install',
].join('\n'),
},
],
},
synthetic: {
steps: [
{
name: 'Install dependencies and build',
run: [
'for attempt in 1 2 3; do',
' npx playwright install --with-deps chromium && break',
'done',
'corepack pnpm install --frozen-lockfile --prefer-offline --reporter=append-only',
].join('\n'),
},
{
name: 'Install integration dependencies',
run: 'cd integration-tests && pnpm install',
},
{ name: 'Time the install', run: 'time pnpm install' },
{
name: 'Retry the install once',
run: 'for attempt in 1; do pnpm install --prefer-offline; done',
},
{
name: 'Mention the recipe',
run: '# pnpm install is retried elsewhere\necho done',
},
],
},
};
expect(findUnretriedInstalls(jobs)).toEqual([
'build/Install dependencies',
'synthetic/Install dependencies and build',
'synthetic/Install integration dependencies',
'synthetic/Time the install',
'synthetic/Retry the install once',
]);
});
});
it('routes Linux E2E scratch files away from /tmp', () => {
expect(e2eRunScript).toContain('mktemp -d /var/tmp/qwen-ci-XXXXXX');
expect(e2eRunScript).toContain('rm -rf "$QWEN_CI_TMPDIR"');
});
});