mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-11-07 06:19:43 -10:00
* ci: build the PR tree once and shorten the required path The no-AK integration gate, the four web-shell smoke shards and Lint & Static each rebuilt the tree test_build already uploads, and the build cache rarely helped: most runs logged "29 of 29 workspaces changed" because the arch-keyed cache was split across the mixed x64/ARM64 pool and a prefix restore returned the newest entry instead of the base commit's. - test_build restores the build its base commit saved (ci-build-cache.mjs key --at), drops runner.arch from the key, and runs the serve fast-path closure and core subpath checks against the built tree. - The no-AK integration gate unpacks that tree and runs as three shards; integration_no_ak_result keeps the required check name. - The web-shell smoke unpacks the same tree instead of building; consumers recopy the opentui native assets for their host (host-assets). - The standalone runtime regression moves from unit shard 1 to shard 4. - A java_only profile skips the TypeScript lanes for changes confined to the Java packages; Lint & Static runs the scripts/tests suites that name them. - Drafts skip the integration gate and the browser smoke; ready_for_review starts the full run. * ci: check the settings schema in test_build, which has the core dist Lint & Static only had core's dist because the serve fast-path check built it; with that check moved, Generate settings schema failed to import it. * ci: keep the standalone regression on unit shard 1 Moving it did not shorten shard 1: the regression runs in the side lane, in parallel with the CLI and core suites, and shard 1's time is its CLI slice (576 s against 147 s on shard 4 for the same 181 files). That imbalance is left to duration-based sharding.
1324 lines
56 KiB
JavaScript
1324 lines
56 KiB
JavaScript
/**
|
|
* @license
|
|
* Copyright 2026 Qwen Team
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import { execFileSync } from 'node:child_process';
|
|
import {
|
|
existsSync,
|
|
mkdirSync,
|
|
mkdtempSync,
|
|
readFileSync,
|
|
realpathSync,
|
|
rmSync,
|
|
symlinkSync,
|
|
} from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import path from 'node:path';
|
|
import { globSync } from 'glob';
|
|
import { describe, expect, it } from 'vitest';
|
|
|
|
import integrationConfig from '../../integration-tests/vitest.config.js';
|
|
import { getWorkflowJob, getWorkflowStep } from './workflow-helpers.js';
|
|
|
|
const ROOT = path.resolve(import.meta.dirname, '../..');
|
|
const NO_AK_SCRIPT = 'test:integration:no-ak:sandbox:none';
|
|
const INTEGRATION_TYPECHECK_SCRIPT = 'typecheck:integration';
|
|
const GUARD_ACTION_PATH = '.github/actions/verify-checkout-head/action.yml';
|
|
const CONFIGURE_ACTION_PATH =
|
|
'.github/actions/configure-windows-runner/action.yml';
|
|
const NODE_ACTION_PATH = '.github/actions/self-hosted-node/action.yml';
|
|
const GUARD_STEP = 'Verify checkout includes expected head commit';
|
|
|
|
describe('no-AK integration CI wiring', () => {
|
|
it.runIf(process.platform === 'linux')(
|
|
'keeps Linux Unix socket paths short and identity-stable',
|
|
() => {
|
|
const workflow = readFileSync(
|
|
path.join(ROOT, '.github/workflows/ci.yml'),
|
|
'utf8',
|
|
);
|
|
const routingBlocks = ['test', 'test_macos_shards', 'test_windows'].map(
|
|
(jobName) => {
|
|
const testStep = getWorkflowStep(
|
|
getWorkflowJob(workflow, jobName),
|
|
'Run tests and generate reports',
|
|
);
|
|
const start = testStep.indexOf('export TMPDIR=');
|
|
expect(
|
|
start,
|
|
`${jobName}: TMPDIR routing block`,
|
|
).toBeGreaterThanOrEqual(0);
|
|
const end = testStep.indexOf('\n ( while true', start);
|
|
expect(end, `${jobName}: sampler sentinel`).toBeGreaterThan(start);
|
|
return testStep.slice(start, end);
|
|
},
|
|
);
|
|
expect(new Set(routingBlocks)).toHaveLength(1);
|
|
const [routeTemp] = routingBlocks;
|
|
const root = mkdtempSync(path.join(tmpdir(), 'ci-temp-routing-'));
|
|
const longRunnerTemp = path.join(root, 'x'.repeat(180));
|
|
|
|
try {
|
|
mkdirSync(longRunnerTemp);
|
|
const [routedTemp, resolvedTemp] = execFileSync(
|
|
'bash',
|
|
[
|
|
'-c',
|
|
`${routeTemp}\nprintf '%s\\n%s\\n' "$TMPDIR" "$(cd "$TMPDIR" && pwd -P)"`,
|
|
],
|
|
{
|
|
encoding: 'utf8',
|
|
env: {
|
|
...process.env,
|
|
RUNNER_OS: 'Linux',
|
|
RUNNER_TEMP: longRunnerTemp,
|
|
},
|
|
},
|
|
)
|
|
.trim()
|
|
.split('\n');
|
|
|
|
expect(resolvedTemp).toBe(routedTemp);
|
|
expect(routedTemp).toMatch(/^\/var\/tmp\/qwen-ci-/);
|
|
expect(existsSync(routedTemp)).toBe(false);
|
|
expect(
|
|
Buffer.byteLength(
|
|
path.join(routedTemp, 'qwen-agent-view-XXXXXX', 'supervisor.sock'),
|
|
),
|
|
).toBeLessThan(108);
|
|
expect(
|
|
workflow.match(/mktemp -d \/var\/tmp\/qwen-ci-XXXXXX/g),
|
|
).toHaveLength(3);
|
|
expect(workflow).toContain('QWEN_CI_TMPDIR="$(mktemp -d');
|
|
expect(workflow).toContain('if [ -n "$QWEN_CI_TMPDIR" ]; then');
|
|
} finally {
|
|
rmSync(root, { recursive: true, force: true });
|
|
}
|
|
},
|
|
);
|
|
|
|
it('preserves test failures in every wrapped OS job', () => {
|
|
const workflow = readFileSync(
|
|
path.join(ROOT, '.github/workflows/ci.yml'),
|
|
'utf8',
|
|
);
|
|
|
|
for (const jobName of ['test', 'test_macos_shards']) {
|
|
const testStep = getWorkflowStep(
|
|
getWorkflowJob(workflow, jobName),
|
|
'Run tests and generate reports',
|
|
);
|
|
expect(testStep).toContain(
|
|
'trap \'rm -rf "$TMPDIR" 2>/dev/null || true\' EXIT',
|
|
);
|
|
let previous = -1;
|
|
for (const command of [
|
|
'set +e',
|
|
// The Linux job runs the suites through the lane script.
|
|
jobName === 'test'
|
|
? '.github/scripts/ci/run-unit-test-lanes.sh'
|
|
: 'node scripts/run-macos-ci-group.js',
|
|
'RC=$?',
|
|
'set -e',
|
|
'pkill -TERM -P "$SAMPLER_PID" 2>/dev/null || true',
|
|
'kill "$SAMPLER_PID" 2>/dev/null || true',
|
|
'exit "$RC"',
|
|
]) {
|
|
const index = testStep.indexOf(command);
|
|
expect(index, `${jobName}: ${command}`).toBeGreaterThan(previous);
|
|
previous = index;
|
|
}
|
|
}
|
|
|
|
const windowsStep = getWorkflowStep(
|
|
getWorkflowJob(workflow, 'test_windows'),
|
|
'Run tests and generate reports',
|
|
);
|
|
let previous = -1;
|
|
for (const command of [
|
|
'set +e',
|
|
'npm run test:ci:workspaces',
|
|
'WORKSPACE_RC=$?',
|
|
'if [ "$WORKSPACE_RC" -ne 0 ]; then',
|
|
'::warning::The Windows workspace suite has known baseline failures',
|
|
'npm run test:scripts',
|
|
'SCRIPTS_RC=$?',
|
|
'if [ "$SCRIPTS_RC" -ne 0 ]; then',
|
|
'::warning::The complete Windows script suite has known baseline failures',
|
|
'npx vitest run scripts/tests/install-script.test.js scripts/tests/opentui-preview-install.test.js --config scripts/tests/vitest.config.ts --retry=0 --bail=1 --maxWorkers=1 --minWorkers=1 --dangerouslyIgnoreUnhandledErrors=false',
|
|
'INSTALLER_RC=$?',
|
|
'set -e',
|
|
'pkill -TERM -P "$SAMPLER_PID" 2>/dev/null || true',
|
|
'kill "$SAMPLER_PID" 2>/dev/null || true',
|
|
'exit "$INSTALLER_RC"',
|
|
]) {
|
|
const index = windowsStep.indexOf(command);
|
|
expect(index, `test_windows: ${command}`).toBeGreaterThan(previous);
|
|
previous = index;
|
|
}
|
|
});
|
|
|
|
it('uses a tunable max for every Vitest pool on ECS', () => {
|
|
const workflow = readFileSync(
|
|
path.join(ROOT, '.github/workflows/ci.yml'),
|
|
'utf8',
|
|
);
|
|
const testStep = getWorkflowStep(
|
|
getWorkflowJob(workflow, 'test'),
|
|
'Run tests and generate reports',
|
|
);
|
|
|
|
for (const name of ['VITEST_MAX_THREADS', 'VITEST_MAX_FORKS']) {
|
|
expect(testStep).toContain(
|
|
`${name}: "\${{ startsWith(runner.name, 'ecs-qwen-') && (vars.QWEN_CI_VITEST_MAX_WORKERS || '4') || '' }}"`,
|
|
);
|
|
}
|
|
for (const name of ['VITEST_MIN_THREADS', 'VITEST_MIN_FORKS']) {
|
|
expect(testStep).toContain(
|
|
`${name}: "\${{ startsWith(runner.name, 'ecs-qwen-') && '1' || '' }}"`,
|
|
);
|
|
}
|
|
// The latency-budget skip shares this fleet predicate; if the line is
|
|
// dropped, renamed, or its expression altered, every millisecond budget
|
|
// silently dies on every lane while the helper tests stay green.
|
|
expect(testStep).toContain(
|
|
`QWEN_SKIP_LATENCY_BUDGETS: "\${{ startsWith(runner.name, 'ecs-qwen-') && '1' || '' }}"`,
|
|
);
|
|
});
|
|
|
|
it('defines a focused no-AK integration script', () => {
|
|
const packageJson = JSON.parse(
|
|
readFileSync(path.join(ROOT, 'package.json'), 'utf8'),
|
|
);
|
|
|
|
expect(packageJson.scripts[INTEGRATION_TYPECHECK_SCRIPT]).toBe(
|
|
'tsc -p integration-tests/tsconfig.json --pretty false',
|
|
);
|
|
expect(packageJson.scripts.typecheck).toContain(
|
|
`npm run ${INTEGRATION_TYPECHECK_SCRIPT}`,
|
|
);
|
|
const expectedFiles = [
|
|
'./fake-openai-server.test.ts',
|
|
'./test-helper.test.ts',
|
|
'./chat-transcript-contract.test.ts',
|
|
'./skill-hooks-invocation-parity.test.ts',
|
|
'./skill-hooks-resume.test.ts',
|
|
'./qwen-live-m4-acp-call.test.ts',
|
|
'./qwen-live-m4-acp-permission.test.ts',
|
|
'./qwen-live-m4-acp-steering.test.ts',
|
|
'./qwen-live-m4-acp-multibackend.test.ts',
|
|
'./qwen-live-m1-call.test.ts',
|
|
'./qwen-live-m2-inject.test.ts',
|
|
'./qwen-live-m2-permission.test.ts',
|
|
'./qwen-live-m2-steering.test.ts',
|
|
'./cli/_prompt-latency-policy.test.ts',
|
|
'./cli/agent-loop-parity.test.ts',
|
|
'./cli/acp-agent-loop-parity.test.ts',
|
|
'./cli/hosted-harness-process.test.ts',
|
|
'./cli/daemon-invocation-context.test.ts',
|
|
'./cli/headless-workflow-skill.test.ts',
|
|
'./cli/workflow-ancestor-discovery.test.ts',
|
|
'./cli/daemon-workflow-ancestor-trust.test.ts',
|
|
'./cli/workflow-intent.test.ts',
|
|
'./cli/workflow-mode.test.ts',
|
|
'./cli/acp-workflow-mode.test.ts',
|
|
'./cli/daemon-workflow-intent.test.ts',
|
|
'./cli/daemon-workflow-mode.test.ts',
|
|
'./cli/list_directory.test.ts',
|
|
'./cli/tool-hook-context.test.ts',
|
|
'./cli/tool-hook-updated-input.test.ts',
|
|
'./cli/mods-prompt-submit.test.ts',
|
|
'./cli/qwen-serve-conversations-root.test.ts',
|
|
'./cli/qwen-serve-routes.test.ts',
|
|
'./cli/qwen-serve-session-cd.test.ts',
|
|
'./cli/qwen-serve-streaming.test.ts',
|
|
'./cli/qwen-serve-standalone-concurrency.test.ts',
|
|
'./helpers/hosted-sse-observer.test.ts',
|
|
'./interactive/workflow-completion.test.ts',
|
|
'./interactive/workflow-intent.test.ts',
|
|
'./interactive/workflow-mode.test.ts',
|
|
'./interactive/mods-tool-middleware.test.ts',
|
|
'./interactive/mods-prompt-submit.test.ts',
|
|
'./interactive/statusline-parity.test.ts',
|
|
'./interactive/tui-editor-parity.test.ts',
|
|
'./interactive/tui-input-ownership.test.ts',
|
|
'./interactive/ink-agent-loop-parity.test.ts',
|
|
'./sdk-typescript/abort-and-lifecycle.test.ts',
|
|
'./sdk-typescript/permission-control.test.ts',
|
|
'./sdk-typescript/sdk-mcp-server.test.ts',
|
|
'./sdk-typescript/subagents.test.ts',
|
|
'./sdk-typescript/system-control.test.ts',
|
|
'./sdk-typescript/tool-control.test.ts',
|
|
'./sdk-typescript/workflow-intent.test.ts',
|
|
'./sdk-typescript/workflow-mode.test.ts',
|
|
];
|
|
expect(packageJson.scripts[NO_AK_SCRIPT]).toBe(
|
|
[
|
|
'cross-env QWEN_SANDBOX=false vitest run --root ./integration-tests --poolOptions.forks.maxForks 2',
|
|
...expectedFiles,
|
|
].join(' '),
|
|
);
|
|
// Vitest positional args are filename filters, not paths: a renamed entry
|
|
// matches nothing while the other filters keep vitest exiting 0, so the
|
|
// gate would report pass with that file's coverage silently gone.
|
|
for (const entry of expectedFiles) {
|
|
expect(
|
|
existsSync(path.join(ROOT, 'integration-tests', entry)),
|
|
entry,
|
|
).toBe(true);
|
|
}
|
|
});
|
|
|
|
it('accounts for every runnable integration test and every no-AK filter', () => {
|
|
const integrationRoot = path.join(ROOT, 'integration-tests');
|
|
const collectedFiles = new Set(
|
|
globSync(integrationConfig.test.include, {
|
|
cwd: integrationRoot,
|
|
ignore: integrationConfig.test.exclude,
|
|
dot: true,
|
|
nodir: true,
|
|
posix: true,
|
|
}),
|
|
);
|
|
const candidates = execFileSync(
|
|
'git',
|
|
['ls-files', '-z', '--', 'integration-tests'],
|
|
{ cwd: ROOT, encoding: 'utf8' },
|
|
)
|
|
.split('\0')
|
|
.map((file) => file.slice('integration-tests/'.length))
|
|
.filter((file) => file.endsWith('.test.ts') && collectedFiles.has(file));
|
|
const packageJson = JSON.parse(
|
|
readFileSync(path.join(ROOT, 'package.json'), 'utf8'),
|
|
);
|
|
const entries = packageJson.scripts[NO_AK_SCRIPT].split(/\s+/).filter(
|
|
(arg, index, args) =>
|
|
arg.startsWith('./') && args[index - 1] !== '--root',
|
|
);
|
|
const selected = new Set();
|
|
for (const entry of entries) {
|
|
const filter = entry.slice(2).toLocaleLowerCase();
|
|
const matches = candidates
|
|
.filter((file) => file.toLocaleLowerCase().includes(filter))
|
|
.sort();
|
|
if (entry === './test-helper.test.ts') {
|
|
// Existing substring collision; pin both files without expanding CI.
|
|
expect(matches, entry).toEqual([
|
|
'sdk-typescript/test-helper.test.ts',
|
|
'test-helper.test.ts',
|
|
]);
|
|
} else {
|
|
expect(matches, `${entry}: ${matches.join(', ')}`).toHaveLength(1);
|
|
}
|
|
for (const file of matches) selected.add(file);
|
|
}
|
|
|
|
// The no-AK job has a 20-minute budget. Keep existing omissions explicit;
|
|
// credential-free suites need a runtime-budget decision before enrollment.
|
|
const exemptions = {
|
|
'channel-plugin.test.ts': 'Real model required',
|
|
'chat-transcript-document.test.ts': 'Browser gate; runtime budget',
|
|
'cli/_first-output-benchmark.test.ts': 'Runtime budget',
|
|
'cli/acp-cron.test.ts': 'Runtime budget',
|
|
'cli/acp-integration.test.ts': 'Runtime budget',
|
|
'cli/advisor-tool.test.ts': 'Runtime budget',
|
|
'cli/cron-tools.test.ts': 'Real model required',
|
|
'cli/edit.test.ts': 'Real model required',
|
|
'cli/extensions-install.test.ts': 'Runtime budget',
|
|
'cli/external-context-mem0-daemon-delete.test.ts': 'Runtime budget',
|
|
'cli/external-context-mem0-daemon-write.test.ts': 'Runtime budget',
|
|
'cli/file-system.test.ts': 'Real model required',
|
|
'cli/gen-ai-telemetry.test.ts': 'Runtime budget',
|
|
'cli/hosted-latency-baseline.test.ts': 'Runtime budget',
|
|
'cli/json-output.test.ts': 'Runtime budget',
|
|
'cli/json-schema.test.ts': 'Real model required',
|
|
'cli/mcp_server_cyclic_schema.test.ts': 'Real model required',
|
|
'cli/mock-acp-typecheck.test.ts': 'Runtime budget',
|
|
'cli/monitor.test.ts': 'Real model required',
|
|
'cli/notebook-edit.test.ts': 'Real model required',
|
|
'cli/qwen-config-dir.test.ts': 'Real model required',
|
|
'cli/qwen-daemon-startup-benchmark.test.ts': 'Benchmark runtime budget',
|
|
'cli/qwen-daemon-vs-cli-benchmark.test.ts': 'Benchmark runtime budget',
|
|
'cli/qwen-serve-baseline.test.ts': 'Benchmark runtime budget',
|
|
'cli/qwen-serve-channel-workers.test.ts': 'Runtime budget',
|
|
'cli/qwen-serve-client-mcp.test.ts': 'Runtime budget',
|
|
'cli/qwen-serve-live-journal-recovery.test.ts': 'Runtime budget',
|
|
'cli/qwen-serve-web-shell-live-journal-recovery.test.ts':
|
|
'Runtime budget',
|
|
'cli/read_many_files.test.ts': 'Real model required',
|
|
'cli/run_shell_command.test.ts': 'Real model required',
|
|
'cli/save_memory.test.ts': 'Real model required',
|
|
'cli/settings-migration.test.ts': 'Runtime budget',
|
|
'cli/simple-mcp-server.test.ts': 'Real model required',
|
|
'cli/sleep-interception.test.ts': 'Real model required',
|
|
'cli/stdin-context.test.ts': 'Real model required',
|
|
'cli/subagent-hook-owner.test.ts': 'Runtime budget',
|
|
'cli/telemetry.test.ts': 'Real model required',
|
|
'cli/todo_write.test.ts': 'Real model required',
|
|
'cli/tool-search.test.ts': 'Real model required',
|
|
'cli/update-target-version.test.ts': 'Runtime budget',
|
|
'cli/utf-bom-encoding.test.ts': 'Runtime budget',
|
|
'cli/write_file.test.ts': 'Real model required',
|
|
'export-artifact-contract.test.ts': 'Runtime budget',
|
|
'globalSetup.test.ts': 'Runtime budget',
|
|
'helpers/hosted-relay-headers.test.ts': 'Runtime budget',
|
|
'helpers/wire-rows.test.ts': 'Runtime budget',
|
|
'interactive/command-output-visibility.test.ts': 'Runtime budget',
|
|
'interactive/context-compress-interactive.test.ts': 'Runtime budget',
|
|
'interactive/cron-interactive.test.ts': 'Real model required',
|
|
'interactive/ctrl-c-exit.test.ts': 'Runtime budget',
|
|
'interactive/external-context-auto-recall.test.ts': 'Runtime budget',
|
|
'interactive/external-context-mem0-write.test.ts': 'Runtime budget',
|
|
'interactive/file-system-interactive.test.ts': 'Runtime budget',
|
|
'interactive/hooks-command.test.ts': 'Runtime budget',
|
|
'interactive/interactive-session-viewport.test.ts': 'Runtime budget',
|
|
'interactive/interactive-session.test.ts': 'Runtime budget',
|
|
'interactive/mid-turn-submit-interactive.test.ts': 'Runtime budget',
|
|
'interactive/mixed-input-crash.test.ts': 'Runtime budget',
|
|
'interactive/protocol-tags-interactive.test.ts': 'Runtime budget',
|
|
'interactive/submitted-prompt-provenance.test.ts': 'Runtime budget',
|
|
'interactive/tui-checkpoint-restore.test.ts': 'Dedicated TUI parity gate',
|
|
'interactive/tui-clipboard-source.test.ts': 'Dedicated TUI parity gate',
|
|
'interactive/tui-extension-acquisition.test.ts':
|
|
'Dedicated TUI parity gate',
|
|
'interactive/tui-foreground-shell.test.ts': 'Dedicated TUI parity gate',
|
|
'interactive/tui-dual-output.test.ts': 'Dedicated TUI parity gate',
|
|
'interactive/tui-migration.test.ts': 'Runtime budget',
|
|
'interactive/tui-session-recovery.test.ts': 'Dedicated TUI parity gate',
|
|
'renderer-matrix.test.ts': 'Runtime budget',
|
|
'scratch-dir.test.ts': 'Runtime budget',
|
|
'sdk-typescript/configuration-options.test.ts': 'Real model required',
|
|
'sdk-typescript/mcp-server.test.ts': 'Real model required',
|
|
'sdk-typescript/message-event-pairing.test.ts': 'Real model required',
|
|
'sdk-typescript/multi-turn.test.ts': 'Real model required',
|
|
'sdk-typescript/session-id.test.ts': 'Real model required',
|
|
'sdk-typescript/single-turn.test.ts': 'Real model required',
|
|
};
|
|
for (const [file, reason] of Object.entries(exemptions)) {
|
|
expect(reason, `${file}: exemption reason`).toMatch(/\S/);
|
|
}
|
|
expect(
|
|
candidates.filter((file) => !selected.has(file)).sort(),
|
|
'Every runnable test must be selected or explicitly exempted with a reason',
|
|
).toEqual(Object.keys(exemptions).sort());
|
|
});
|
|
|
|
it('runs the no-AK integration script as its own check on PRs and the merge queue', () => {
|
|
// The gate ran as a step inside the Ubuntu `test` job from #8313 until
|
|
// it moved out: a step is invisible to anything that reads check names,
|
|
// and the PR review bot ruled from the (merge_group-only, hence skipped
|
|
// on every PR) `Integration Tests (CLI, No Sandbox)` check that a changed
|
|
// integration test "never ran" while this gate had executed it and
|
|
// passed inside `test` (#9895 round 15). A check of its own carries the
|
|
// fact in its name. The env isolation and the two-event condition are
|
|
// the gate's contract and must survive the move unchanged.
|
|
const workflow = readFileSync(
|
|
path.join(ROOT, '.github/workflows/ci.yml'),
|
|
'utf8',
|
|
);
|
|
const ubuntuJob = getWorkflowJob(workflow, 'test');
|
|
const macosJob = getWorkflowJob(workflow, 'test_macos_shards');
|
|
const windowsJob = getWorkflowJob(workflow, 'test_windows');
|
|
const gateJob = getWorkflowJob(workflow, 'integration_no_ak');
|
|
const classifyJob = getWorkflowJob(workflow, 'classify_pr');
|
|
const permissionsIndex = workflow.indexOf('\npermissions:');
|
|
expect(permissionsIndex).toBeGreaterThan(0);
|
|
const workflowTriggers = workflow.slice(0, permissionsIndex);
|
|
expect(workflowTriggers).toContain('\n pull_request:\n');
|
|
expect(workflowTriggers).toContain('\n merge_group:\n');
|
|
|
|
expect(workflow.split(`npm run ${NO_AK_SCRIPT}`).length - 1).toBe(1);
|
|
for (const [name, job] of Object.entries({
|
|
test: ubuntuJob,
|
|
test_macos_shards: macosJob,
|
|
test_windows: windowsJob,
|
|
})) {
|
|
expect(job, `${name} must not run the no-AK script`).not.toContain(
|
|
NO_AK_SCRIPT,
|
|
);
|
|
}
|
|
|
|
// The gate runs as three shards over test_build's tree; the aggregate
|
|
// job carries the required check name and fails when the build or any
|
|
// shard fails.
|
|
expect(gateJob).toContain(
|
|
" name: 'Integration Tests (no-AK, shard ${{ matrix.shard }}/3)'",
|
|
);
|
|
expect(gateJob).toContain(
|
|
" needs:\n - 'classify_pr'\n - 'test_build'\n",
|
|
);
|
|
expect(gateJob).toContain(' shard: [1, 2, 3]');
|
|
expect(gateJob).toContain(
|
|
`npm run ${NO_AK_SCRIPT} -- --shard=\${{ matrix.shard }}/3`,
|
|
);
|
|
const resultJob = getWorkflowJob(workflow, 'integration_no_ak_result');
|
|
expect(resultJob).toContain(
|
|
" name: 'Integration Tests (no-AK, No Sandbox)'",
|
|
);
|
|
expect(resultJob).toContain(
|
|
" - 'test_build'\n - 'integration_no_ak'\n",
|
|
);
|
|
expect(resultJob).toContain(
|
|
'test "$BUILD_RESULT" = success && test "$SHARDS_RESULT" = success',
|
|
);
|
|
expect(
|
|
resultJob.split('\n').find((line) => line.startsWith(' if:')),
|
|
).toBe(gateJob.split('\n').find((line) => line.startsWith(' if:')));
|
|
// Same trust class as lint (PR code on the pool, zero secrets by
|
|
// construction — 2026-11-02 extension of the 2026-10-31 decision),
|
|
// never a hard-coded pool. Pin the runs-on line itself: the timeout line
|
|
// also mentions lint_runner, so a bare substring stays green under a
|
|
// runs-on-only revert.
|
|
expect(gateJob).toContain(
|
|
"runs-on: '${{ fromJSON(needs.classify_pr.outputs.lint_runner",
|
|
);
|
|
const jobIf = gateJob
|
|
.split('\n')
|
|
.find((line) => line.startsWith(' if:'));
|
|
expect(jobIf).toContain("needs.classify_pr.outputs.skip_ci != 'true'");
|
|
expect(jobIf).toContain(
|
|
"(github.event_name == 'pull_request' || github.event_name == 'merge_group')",
|
|
);
|
|
// Event PR head SHA on pull_request, queue head on merge_group — the same
|
|
// shape the Ubuntu gate uses, so this check tests the pushed tree, not
|
|
// the lagging merge ref.
|
|
expect(getWorkflowStep(gateJob, 'Checkout')).toContain(
|
|
"github.event_name == 'pull_request' && github.event.pull_request.head.sha",
|
|
);
|
|
const trustedClassifierCheckout = getWorkflowStep(
|
|
classifyJob,
|
|
'Checkout trusted CI classifier',
|
|
);
|
|
expect(classifyJob).toContain(
|
|
"ci_profile: '${{ steps.ci_profile.outputs.ci_profile }}'",
|
|
);
|
|
// The classifier wrapper calls `repos/{}/pulls/{}/files` and
|
|
// `repos/{}/pulls/{}`, so the job token needs `pull-requests` scope:
|
|
// the workflow-level grant lists none, and without a job-level block a
|
|
// same-repo PR's file listing 403s and classification falls back to
|
|
// `full` — a regression against the base lane that classified under
|
|
// `pull-requests: 'write'` (#10548 verify F1).
|
|
expect(classifyJob).toContain(
|
|
" permissions:\n contents: 'read'\n pull-requests: 'read'",
|
|
);
|
|
expect(trustedClassifierCheckout).toContain(
|
|
'if: "${{ github.event_name == \'pull_request\' }}"',
|
|
);
|
|
expect(trustedClassifierCheckout).toContain(
|
|
"repository: '${{ github.repository }}'",
|
|
);
|
|
expect(trustedClassifierCheckout).toContain(
|
|
"ref: '${{ github.event.pull_request.base.sha }}'",
|
|
);
|
|
expect(trustedClassifierCheckout).toContain(
|
|
"path: 'trusted-ci-classifier'",
|
|
);
|
|
expect(trustedClassifierCheckout).toContain(
|
|
"sparse-checkout: '.github/scripts/ci'",
|
|
);
|
|
expect(trustedClassifierCheckout).toContain('persist-credentials: false');
|
|
expect(trustedClassifierCheckout).not.toContain('head.sha');
|
|
|
|
const trustedClassifier = getWorkflowStep(
|
|
classifyJob,
|
|
'Classify CI profile from trusted base',
|
|
);
|
|
expect(trustedClassifier).toContain(
|
|
'trusted-ci-classifier/.github/scripts/ci/classify-pr-profile.sh "${GITHUB_REPOSITORY}" "${PR_NUMBER}"',
|
|
);
|
|
expect(trustedClassifier).toContain(
|
|
'docs_only|github_ci_only|java_only|full) ;;',
|
|
);
|
|
expect(trustedClassifier).toContain('profile=full');
|
|
// The classify_pr job output resolves against the step ID: renaming it
|
|
// empties the output and every consumer silently falls back to `full` —
|
|
// fail-safe direction, but a silent perf regression nothing would notice.
|
|
expect(trustedClassifier).toContain("id: 'ci_profile'");
|
|
expect(trustedClassifier).toContain(
|
|
'echo "ci_profile=${profile}" >> "${GITHUB_OUTPUT}"',
|
|
);
|
|
expect(classifyJob).not.toContain('collaborators/${PR_AUTHOR}/permission');
|
|
expect(classifyJob).not.toContain('CI_BOT_PAT');
|
|
expect(workflow).toContain(
|
|
'.github/scripts/update-ecs-runner-qwen-workflow.test.mjs',
|
|
);
|
|
|
|
// Every consumer uses the profile that was already computed from the
|
|
// base checkout. None may execute a classifier from the PR checkout —
|
|
// and none may repoint the env binding to a literal: `profile=
|
|
// "\${TRUSTED_CI_PROFILE:-full}"` falls back silently, so a dropped
|
|
// binding turns every docs_only/github_ci_only PR into a full 34-step
|
|
// pool run with no degraded-path warning.
|
|
for (const profileStep of [
|
|
getWorkflowStep(ubuntuJob, 'Use trusted CI profile'),
|
|
getWorkflowStep(
|
|
getWorkflowJob(workflow, 'lint_and_static'),
|
|
'Use trusted CI profile',
|
|
),
|
|
getWorkflowStep(gateJob, 'Use trusted CI profile'),
|
|
]) {
|
|
expect(profileStep).toContain("id: 'ci_profile'");
|
|
expect(profileStep).toContain(
|
|
"TRUSTED_CI_PROFILE: '${{ needs.classify_pr.outputs.ci_profile }}'",
|
|
);
|
|
expect(profileStep).toContain('profile="${TRUSTED_CI_PROFILE:-full}"');
|
|
// The allowlist case line and the output write are load-bearing in
|
|
// the consumers too: dropping the echo empties
|
|
// steps.ci_profile.outputs.ci_profile, so every downstream profile
|
|
// gate mis-compares; dropping the case line lets an unexpected
|
|
// profile value pass through unnormalized.
|
|
// java_only either passes through (the gate and test skip their
|
|
// steps on it) or, in lint_and_static, maps to its full payload.
|
|
expect(profileStep).toMatch(
|
|
/docs_only\|github_ci_only\|(?:java_only\|)?full\) ;;/,
|
|
);
|
|
expect(profileStep).toContain('java_only');
|
|
expect(profileStep).toContain(
|
|
'echo "ci_profile=${profile}" >> "${GITHUB_OUTPUT}"',
|
|
);
|
|
// Degraded path (classify_pr failed or was skipped, so the output is
|
|
// empty) must not log a byte-identical line to a legitimate `full`
|
|
// classification: the breadcrumb is the only signal that distinguishes a
|
|
// producer failure from a PR that really is full-profile.
|
|
expect(profileStep).toContain('if [ -z "${TRUSTED_CI_PROFILE}" ]; then');
|
|
expect(profileStep).toContain(
|
|
'echo "::warning::classify_pr produced no ci_profile output (classifier job failed or was skipped); running full CI."',
|
|
);
|
|
expect(profileStep).not.toContain('classify-pr-profile.sh');
|
|
expect(profileStep).not.toContain('GH_TOKEN');
|
|
}
|
|
for (const stepName of [
|
|
'Setup Node.js (hosted)',
|
|
'Use pre-installed Node.js (self-hosted)',
|
|
'Disk floor gate (self-hosted)',
|
|
'Install Dependencies',
|
|
'Run required no-AK integration gate',
|
|
]) {
|
|
expect(
|
|
getWorkflowStep(gateJob, stepName),
|
|
`${stepName} must honour the CI profile`,
|
|
).toContain("steps.ci_profile.outputs.ci_profile == 'full'");
|
|
}
|
|
|
|
const diskFloorGate = getWorkflowStep(
|
|
gateJob,
|
|
'Disk floor gate (self-hosted)',
|
|
);
|
|
expect(diskFloorGate).toContain(
|
|
"if: \"${{ steps.ci_profile.outputs.ci_profile == 'full' && runner.environment == 'self-hosted' }}\"",
|
|
);
|
|
expect(diskFloorGate).toContain(
|
|
'run: \'bash .github/scripts/check-disk-floor.sh "${GITHUB_WORKSPACE}" "${RUNNER_TEMP:-/tmp}"\'',
|
|
);
|
|
expect(diskFloorGate).not.toContain('continue-on-error');
|
|
expect(diskFloorGate).not.toContain('|| true');
|
|
expect(diskFloorGate).not.toContain('env:');
|
|
expect(gateJob.indexOf("id: 'ci_profile'")).toBeLessThan(
|
|
gateJob.indexOf("name: 'Disk floor gate (self-hosted)'"),
|
|
);
|
|
expect(
|
|
gateJob.indexOf("name: 'Disk floor gate (self-hosted)'"),
|
|
).toBeLessThan(gateJob.indexOf("name: 'Install Dependencies'"));
|
|
|
|
const gateStep = getWorkflowStep(
|
|
gateJob,
|
|
'Run required no-AK integration gate',
|
|
);
|
|
const integrationTypecheckCommand = `npm run ${INTEGRATION_TYPECHECK_SCRIPT}`;
|
|
expect(gateStep).toContain(integrationTypecheckCommand);
|
|
expect(gateStep).toContain(`npm run ${NO_AK_SCRIPT}`);
|
|
expect(gateStep.indexOf(integrationTypecheckCommand)).toBeLessThan(
|
|
gateStep.indexOf(`npm run ${NO_AK_SCRIPT}`),
|
|
);
|
|
// The job's first step publishes the run-unique HOME via GITHUB_ENV
|
|
// (2026-11-02 round-2: fork code starts at checkout/install, not at the
|
|
// gate — HOME/USERPROFILE are job-wide, not gate-scoped); the gate keeps
|
|
// QWEN_HOME + the key scrub.
|
|
expect(gateStep).toContain(
|
|
"QWEN_HOME: '${{ runner.temp }}/qwen-no-ak-home-${{ github.run_id }}-${{ github.run_attempt }}/.qwen'",
|
|
);
|
|
expect(gateStep).toContain('timeout-minutes: 20');
|
|
// HOME/USERPROFILE are job-wide now (first-step GITHUB_ENV), so the
|
|
// gate must not re-pin them step-locally.
|
|
expect(gateStep).not.toContain('\n HOME: ');
|
|
expect(gateStep).not.toContain('\n USERPROFILE: ');
|
|
for (const key of [
|
|
'API_KEY',
|
|
'ANTHROPIC_API_KEY',
|
|
'ANTHROPIC_AUTH_TOKEN',
|
|
'ANTHROPIC_BASE_URL',
|
|
'ANTHROPIC_MODEL',
|
|
'BAILIAN_CODING_PLAN_API_KEY',
|
|
'BAILIAN_TOKEN_PLAN_API_KEY',
|
|
'DASHSCOPE_API_KEY',
|
|
'DEEPSEEK_API_KEY',
|
|
'GEMINI_API_KEY',
|
|
'GEMINI_MODEL',
|
|
'GOOGLE_API_KEY',
|
|
'GOOGLE_MODEL',
|
|
'IDEALAB_API_KEY',
|
|
'MINIMAX_API_KEY',
|
|
'MODELSCOPE_API_KEY',
|
|
'MOONSHOT_API_KEY',
|
|
'OPENAI_API_KEY',
|
|
'OPENAI_BASE_URL',
|
|
'OPENAI_MODEL',
|
|
'OPENROUTER_API_KEY',
|
|
'QWEN_API_KEY',
|
|
'QWENCLOUD_API_KEY',
|
|
'QWENCLOUD_TOKEN_PLAN_API_KEY',
|
|
'QWEN_DEFAULT_AUTH_TYPE',
|
|
'QWEN_MODEL',
|
|
'REQUESTY_API_KEY',
|
|
'XAI_API_KEY',
|
|
'ZAI_API_KEY',
|
|
]) {
|
|
expect(gateStep).toContain(`\n ${key}: ''`);
|
|
}
|
|
for (const job of [ubuntuJob, gateJob]) {
|
|
expect(job).not.toContain('secrets.OPENAI_API_KEY');
|
|
expect(job).not.toContain('secrets.OPENAI_BASE_URL');
|
|
expect(job).not.toContain('secrets.OPENAI_MODEL');
|
|
}
|
|
});
|
|
|
|
it('pins PR checkouts to the event head even after the PR ref advances', () => {
|
|
const workflow = readFileSync(
|
|
path.join(ROOT, '.github/workflows/ci.yml'),
|
|
'utf8',
|
|
);
|
|
const ubuntuJob = getWorkflowJob(workflow, 'test');
|
|
const webShellJob = getWorkflowJob(workflow, 'web_shell_e2e_smoke');
|
|
const lintJob = getWorkflowJob(workflow, 'lint_and_static');
|
|
const windowsJob = getWorkflowJob(workflow, 'test_windows');
|
|
const integrationJob = getWorkflowJob(workflow, 'integration_cli');
|
|
const noAkJob = getWorkflowJob(workflow, 'integration_no_ak');
|
|
|
|
// A retry retains the event head SHA while refs/pull/N/head may advance.
|
|
// Only these four lanes currently run on pull_request.
|
|
for (const job of [ubuntuJob, lintJob, webShellJob, noAkJob]) {
|
|
const checkout = getWorkflowStep(job, 'Checkout');
|
|
expect(checkout).toContain(
|
|
"github.event_name == 'pull_request' && github.event.pull_request.head.sha",
|
|
);
|
|
expect(checkout).not.toContain('refs/pull/');
|
|
expect(checkout).toContain('fetch-depth: 1');
|
|
}
|
|
|
|
// The brittle merge-ref retry/refresh machinery is gone: in particular the
|
|
// direct GitHub fetch (the self-hosted proxy times it out) and the forced
|
|
// merge-ref checkout no longer exist.
|
|
expect(ubuntuJob).not.toContain(
|
|
"name: 'Fetch current PR merge ref from GitHub'",
|
|
);
|
|
expect(ubuntuJob).not.toContain('https://x-access-token:${GITHUB_TOKEN}');
|
|
expect(ubuntuJob).not.toContain('git checkout --force "${merge_ref}"');
|
|
expect(ubuntuJob).not.toContain(
|
|
"name: 'Back off for stale merge ref to refresh'",
|
|
);
|
|
|
|
// The cheap sanity guard stays: fail loud if HEAD lacks the expected head
|
|
// (PR head, or the merge-queue head once a job also runs on merge_group).
|
|
// Its body lives in one composite action so the four gates cannot drift;
|
|
// each caller keeps its own run condition and expected-SHA expression.
|
|
const guardAction = readFileSync(
|
|
path.join(ROOT, GUARD_ACTION_PATH),
|
|
'utf8',
|
|
);
|
|
// Pin the reject path as one contiguous block so ordering is part of the
|
|
// contract: relocating `exit 1` into a never-taken branch would turn the
|
|
// guard into a pass-through for the stale checkouts it must reject while
|
|
// every substring pin stayed green.
|
|
expect(guardAction).toContain(
|
|
[
|
|
'if ! git merge-base --is-ancestor "${EXPECTED_SHA}" HEAD; then',
|
|
' echo "::error::Checked out ref does not contain expected head ${EXPECTED_SHA}."',
|
|
' git log --oneline --decorate -5',
|
|
' exit 1',
|
|
' fi',
|
|
].join('\n'),
|
|
);
|
|
// Pin the input-to-env wiring too: re-binding EXPECTED_SHA to a context
|
|
// value (e.g. github.sha) would pass for every checkout, including the
|
|
// stale ones this guard must reject, while the pin above stays green.
|
|
expect(guardAction).toContain("EXPECTED_SHA: '${{ inputs.expected_sha }}'");
|
|
|
|
const guardCalls = {
|
|
test: getWorkflowStep(ubuntuJob, GUARD_STEP),
|
|
lint_and_static: getWorkflowStep(lintJob, GUARD_STEP),
|
|
web_shell_e2e_smoke: getWorkflowStep(webShellJob, GUARD_STEP),
|
|
test_windows: getWorkflowStep(windowsJob, GUARD_STEP),
|
|
integration_cli: getWorkflowStep(integrationJob, GUARD_STEP),
|
|
integration_no_ak: getWorkflowStep(noAkJob, GUARD_STEP),
|
|
};
|
|
for (const [jobName, call] of Object.entries(guardCalls)) {
|
|
expect(call, `${jobName} guard must use the shared action`).toContain(
|
|
"uses: './.github/actions/verify-checkout-head'",
|
|
);
|
|
}
|
|
expect(guardCalls.test).toContain(
|
|
'expected_sha: "${{ github.event_name == \'merge_group\' && github.event.merge_group.head_sha || github.event.pull_request.head.sha }}"',
|
|
);
|
|
// Byte-identical to test's event-aware shape: the lint lane replicates
|
|
// the same checkout contract on the same event surface.
|
|
expect(guardCalls.lint_and_static).toContain(
|
|
'expected_sha: "${{ github.event_name == \'merge_group\' && github.event.merge_group.head_sha || github.event.pull_request.head.sha }}"',
|
|
);
|
|
expect(guardCalls.web_shell_e2e_smoke).toContain(
|
|
"expected_sha: '${{ github.event.pull_request.head.sha }}'",
|
|
);
|
|
expect(guardCalls.test_windows).toContain(
|
|
'expected_sha: "${{ github.event_name == \'merge_group\' && github.event.merge_group.head_sha || github.event.pull_request.head.sha }}"',
|
|
);
|
|
expect(guardCalls.integration_cli).toContain(
|
|
"expected_sha: '${{ github.event.merge_group.head_sha }}'",
|
|
);
|
|
expect(guardCalls.integration_no_ak).toContain(
|
|
'expected_sha: "${{ github.event_name == \'merge_group\' && github.event.merge_group.head_sha || github.event.pull_request.head.sha }}"',
|
|
);
|
|
|
|
// The run conditions are part of the guard's contract: scoping a guard to
|
|
// the wrong runner class or dropping an event would silently disable it.
|
|
// integration_cli has no step-level if; its job-level merge_group gate
|
|
// already covers it.
|
|
expect(guardCalls.test).toContain(
|
|
"if: \"${{ needs.classify_pr.outputs.skip_ci != 'true' && (github.event_name == 'pull_request' || github.event_name == 'merge_group') }}\"",
|
|
);
|
|
expect(guardCalls.web_shell_e2e_smoke).toContain(
|
|
'if: "${{ github.event_name == \'pull_request\' }}"',
|
|
);
|
|
expect(guardCalls.test_windows).toContain(
|
|
"if: \"${{ needs.classify_pr.outputs.skip_ci != 'true' && (github.event_name == 'pull_request' || github.event_name == 'merge_group') }}\"",
|
|
);
|
|
expect(guardCalls.integration_cli).not.toContain('if:');
|
|
// integration_no_ak likewise gates skip_ci and both events at job level.
|
|
expect(guardCalls.integration_no_ak).not.toContain('if:');
|
|
});
|
|
|
|
it('pins the Windows gate kill-switch routing, tuning, and Node split', () => {
|
|
const workflow = readFileSync(
|
|
path.join(ROOT, '.github/workflows/ci.yml'),
|
|
'utf8',
|
|
);
|
|
const windowsJob = getWorkflowJob(workflow, 'test_windows');
|
|
|
|
// The runs-on expression is the Windows gate's escape hatch and its fork
|
|
// trust policy: pull requests never reach the pool, because a
|
|
// pull_request run executes the PR's own YAML and could rewrite runs-on
|
|
// itself. Pin the whole line so a variable typo, a quoting regression in
|
|
// the nested ''true'' escapes, or an && / || regrouping fails here
|
|
// instead of surfacing only when the switch is flipped — or when a fork
|
|
// PR finds the persistent pool.
|
|
const windowsRunsOn = windowsJob
|
|
.split('\n')
|
|
.find((line) => line.startsWith(' runs-on:'));
|
|
expect(windowsRunsOn).toBe(
|
|
` runs-on: '\${{ vars.MAINTAINER_ECS_RUNNER_DISABLED != ''true'' && github.event_name != ''pull_request'' && fromJSON(''["self-hosted", "Windows", "X64", "ecs-win"]'') || fromJSON(''["windows-2022"]'') }}'`,
|
|
);
|
|
expect(windowsJob.split('\n')).toContain(' timeout-minutes: 60');
|
|
|
|
// The guard must stay wired to the expected head for this job: the
|
|
// event-aware shape, since the revived triggers have no merge-queue head.
|
|
const guard = getWorkflowStep(windowsJob, GUARD_STEP);
|
|
expect(guard).toContain("uses: './.github/actions/verify-checkout-head'");
|
|
expect(guard).toContain(
|
|
'expected_sha: "${{ github.event_name == \'merge_group\' && github.event.merge_group.head_sha || github.event.pull_request.head.sha }}"',
|
|
);
|
|
|
|
// The self-hosted-only tuning comes from the composite action shared with
|
|
// windows-runner-smoke.yml, and only runs on self-hosted machines.
|
|
const configure = getWorkflowStep(
|
|
windowsJob,
|
|
'Configure self-hosted Windows test environment',
|
|
);
|
|
expect(configure).toContain(
|
|
"if: \"${{ needs.classify_pr.outputs.skip_ci != 'true' && runner.environment == 'self-hosted' }}\"",
|
|
);
|
|
expect(configure).toContain(
|
|
"uses: './.github/actions/configure-windows-runner'",
|
|
);
|
|
const redirectTemp = getWorkflowStep(
|
|
windowsJob,
|
|
'Point temp at a short-alias-free directory',
|
|
);
|
|
expect(redirectTemp).toContain(
|
|
"if: \"${{ needs.classify_pr.outputs.skip_ci != 'true' && runner.environment != 'self-hosted' }}\"",
|
|
);
|
|
// Pin the run block as one contiguous chunk so ordering is part of the
|
|
// contract: under PowerShell 5.1 an undefined $temp expands to empty, so
|
|
// if the assignment moves below the Out-File lines the step still writes
|
|
// TEMP=/TMP= to GITHUB_ENV and exits green. The shell pin matters for the
|
|
// same reason: Out-File's default is UTF-16LE on PowerShell 5.1, so the
|
|
// -Encoding utf8 spelling only carries its meaning there.
|
|
expect(redirectTemp).toContain(
|
|
[
|
|
"$temp = Join-Path $env:RUNNER_WORKSPACE 'qwen-code-temp'",
|
|
'New-Item -ItemType Directory -Force -Path $temp | Out-Null',
|
|
'"TEMP=$temp" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append',
|
|
'"TMP=$temp" | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append',
|
|
]
|
|
.map((line) => ` ${line}`)
|
|
.join('\n'),
|
|
);
|
|
expect(redirectTemp).toContain("shell: 'powershell'");
|
|
const verifyTemp = getWorkflowStep(
|
|
windowsJob,
|
|
'Verify temp paths carry no short alias',
|
|
);
|
|
expect(verifyTemp).toContain(
|
|
'if: "${{ needs.classify_pr.outputs.skip_ci != \'true\' }}"',
|
|
);
|
|
expect(verifyTemp).toContain('fs.realpathSync(value)');
|
|
// The guard's decisions are asserted by executing it, below; pinning the
|
|
// JS text here only fixes its spelling in place.
|
|
const configureAction = readFileSync(
|
|
path.join(ROOT, CONFIGURE_ACTION_PATH),
|
|
'utf8',
|
|
);
|
|
expect(configureAction).toContain("shell: 'powershell'");
|
|
const autocrlfStep = getWorkflowStep(
|
|
windowsJob,
|
|
'Disable Git CRLF conversion (self-hosted)',
|
|
);
|
|
expect(autocrlfStep).toContain(
|
|
"if: \"${{ needs.classify_pr.outputs.skip_ci != 'true' && runner.environment == 'self-hosted' }}\"",
|
|
);
|
|
|
|
// Repository-local `./` actions resolve from the job workspace, so the
|
|
// checkout must precede them or a fresh runner fails before checking out;
|
|
// autocrlf is turned off before the checkout itself (belt-and-braces
|
|
// alongside .gitattributes' eol=lf) so even a freshly provisioned machine
|
|
// checks out LF-only files.
|
|
const windowsCheckoutIndex = windowsJob.indexOf("name: 'Checkout'");
|
|
const autocrlfIndex = windowsJob.indexOf(
|
|
'git config --global core.autocrlf false',
|
|
);
|
|
const configureUseIndex = windowsJob.indexOf(
|
|
"uses: './.github/actions/configure-windows-runner'",
|
|
);
|
|
const redirectTempIndex = windowsJob.indexOf(
|
|
"name: 'Point temp at a short-alias-free directory'",
|
|
);
|
|
const hostedNodeIndex = windowsJob.indexOf('actions/setup-node@');
|
|
const selfHostedNodeIndex = windowsJob.indexOf(
|
|
"uses: './.github/actions/self-hosted-node'",
|
|
);
|
|
const verifyTempIndex = windowsJob.indexOf(
|
|
"name: 'Verify temp paths carry no short alias'",
|
|
);
|
|
const installIndex = windowsJob.indexOf("name: 'Install dependencies'");
|
|
const guardUseIndex = windowsJob.indexOf(
|
|
"uses: './.github/actions/verify-checkout-head'",
|
|
);
|
|
expect(windowsCheckoutIndex).toBeGreaterThanOrEqual(0);
|
|
expect(autocrlfIndex).toBeGreaterThanOrEqual(0);
|
|
expect(autocrlfIndex).toBeLessThan(windowsCheckoutIndex);
|
|
expect(configureUseIndex).toBeGreaterThan(windowsCheckoutIndex);
|
|
expect(redirectTempIndex).toBeGreaterThan(configureUseIndex);
|
|
expect(redirectTempIndex).toBeLessThan(hostedNodeIndex);
|
|
expect(redirectTempIndex).toBeLessThan(selfHostedNodeIndex);
|
|
expect(redirectTempIndex).toBeLessThan(
|
|
windowsJob.indexOf("name: 'Run tests and generate reports'"),
|
|
);
|
|
expect(verifyTempIndex).toBeGreaterThan(hostedNodeIndex);
|
|
expect(verifyTempIndex).toBeGreaterThan(selfHostedNodeIndex);
|
|
expect(verifyTempIndex).toBeLessThan(installIndex);
|
|
expect(guardUseIndex).toBeGreaterThan(windowsCheckoutIndex);
|
|
expect(configureUseIndex).toBeLessThan(guardUseIndex);
|
|
for (const line of [
|
|
'"TEMP=$env:RUNNER_TEMP"',
|
|
'"TMP=$env:RUNNER_TEMP"',
|
|
'"LC_ALL=C.UTF-8"',
|
|
]) {
|
|
expect(configureAction).toContain(
|
|
`${line} | Out-File -FilePath $env:GITHUB_ENV -Encoding utf8 -Append`,
|
|
);
|
|
}
|
|
expect(configureAction).toContain(
|
|
"$gitBash = 'C:\\Program Files\\Git\\bin'",
|
|
);
|
|
expect(configureAction).toContain('Test-Path $gitBash');
|
|
expect(configureAction).toContain(
|
|
'$gitBash | Out-File -FilePath $env:GITHUB_PATH -Encoding utf8 -Append',
|
|
);
|
|
// The runner-validation smoke must consume the same action, or it
|
|
// validates a different configuration than the gate actually uses.
|
|
const smokeWorkflow = readFileSync(
|
|
path.join(ROOT, '.github/workflows/windows-runner-smoke.yml'),
|
|
'utf8',
|
|
);
|
|
expect(smokeWorkflow).toContain(
|
|
"uses: './.github/actions/configure-windows-runner'",
|
|
);
|
|
expect(smokeWorkflow).toContain('npm run test:ci');
|
|
expect(smokeWorkflow).not.toContain(
|
|
'npm run test:ci --workspaces --if-present',
|
|
);
|
|
// Same ordering as the gate: autocrlf off before the checkout, the `./`
|
|
// configure action after it.
|
|
const smokeCheckoutIndex = smokeWorkflow.indexOf("name: 'Checkout'");
|
|
const smokeAutocrlfIndex = smokeWorkflow.indexOf(
|
|
'git config --global core.autocrlf false',
|
|
);
|
|
expect(smokeCheckoutIndex).toBeGreaterThanOrEqual(0);
|
|
expect(smokeAutocrlfIndex).toBeGreaterThanOrEqual(0);
|
|
expect(smokeAutocrlfIndex).toBeLessThan(smokeCheckoutIndex);
|
|
expect(
|
|
smokeWorkflow.indexOf(
|
|
"uses: './.github/actions/configure-windows-runner'",
|
|
),
|
|
).toBeGreaterThan(smokeCheckoutIndex);
|
|
// The smoke runs behind the same caching egress proxy as the gate, so it
|
|
// takes the same stale-checkout guard, pinned to the dispatched head.
|
|
expect(
|
|
smokeWorkflow.indexOf("uses: './.github/actions/verify-checkout-head'"),
|
|
).toBeGreaterThan(smokeCheckoutIndex);
|
|
expect(smokeWorkflow).toContain("expected_sha: '${{ github.sha }}'");
|
|
// The smoke is self-hosted-only, so it must take the same Node path as
|
|
// the gate's self-hosted side: the pre-installed Node, never a nodejs.org
|
|
// download the ECS egress proxy cannot reach.
|
|
expect(smokeWorkflow).toContain(
|
|
"uses: './.github/actions/self-hosted-node'",
|
|
);
|
|
expect(smokeWorkflow).not.toContain('actions/setup-node');
|
|
// The gate's run steps inherit ci.yml's workflow-level bash default, so
|
|
// the smoke must execute these commands under the same shell; a
|
|
// powershell pin there would validate a shell the gate never runs.
|
|
const smokeJob = getWorkflowJob(smokeWorkflow, 'validate');
|
|
for (const stepName of [
|
|
'Configure persistent npm cache (self-hosted)',
|
|
'Configure npm for rate limiting',
|
|
'Install dependencies',
|
|
'Run tests and generate reports',
|
|
]) {
|
|
expect(getWorkflowStep(smokeJob, stepName)).toContain("shell: 'bash'");
|
|
}
|
|
// Both workflows declare the persistent npm cache step; the gate's
|
|
// self-hosted path exports NPM_CONFIG_CACHE for every later npm command.
|
|
expect(
|
|
getWorkflowStep(smokeJob, 'Configure persistent npm cache (self-hosted)'),
|
|
).toContain('NPM_CONFIG_CACHE=');
|
|
const gateNpmCache = getWorkflowStep(
|
|
windowsJob,
|
|
'Configure persistent npm cache (self-hosted)',
|
|
);
|
|
expect(gateNpmCache).toContain(
|
|
"if: \"${{ needs.classify_pr.outputs.skip_ci != 'true' && runner.environment == 'self-hosted' }}\"",
|
|
);
|
|
expect(gateNpmCache).toContain('NPM_CONFIG_CACHE=');
|
|
|
|
// Node split: hosted runners download Node, self-hosted runners reuse
|
|
// their pre-installed one: fail loud when Node is missing, warn when the
|
|
// major is not 22.
|
|
const hostedSetup = getWorkflowStep(
|
|
windowsJob,
|
|
'Set up Node.js 22.x (hosted)',
|
|
);
|
|
expect(hostedSetup).toContain(
|
|
"if: \"${{ needs.classify_pr.outputs.skip_ci != 'true' && runner.environment != 'self-hosted' }}\"",
|
|
);
|
|
const selfHostedNode = getWorkflowStep(
|
|
windowsJob,
|
|
'Use pre-installed Node.js (self-hosted)',
|
|
);
|
|
expect(selfHostedNode).toContain(
|
|
"if: \"${{ needs.classify_pr.outputs.skip_ci != 'true' && runner.environment == 'self-hosted' }}\"",
|
|
);
|
|
expect(selfHostedNode).toContain(
|
|
"uses: './.github/actions/self-hosted-node'",
|
|
);
|
|
const nodeAction = readFileSync(path.join(ROOT, NODE_ACTION_PATH), 'utf8');
|
|
expect(nodeAction).toContain('if ! command -v node >/dev/null 2>&1; then');
|
|
expect(nodeAction).toContain('exit 1');
|
|
expect(nodeAction).toContain(
|
|
'if [[ "$(node -p \'process.versions.node.split(".")[0]\')" != "22" ]]; then',
|
|
);
|
|
expect(nodeAction).toContain('::warning::Expected Node 22.x but found');
|
|
});
|
|
|
|
it('keeps both installer files out of the win32 exclude list', () => {
|
|
// install-script.test.js is the only home of the nine Windows installer
|
|
// end-to-end cases; excluding it on win32 would silently drop coverage
|
|
// docs/design/windows-ecs-ci-validation.md declares required.
|
|
const scriptSuiteConfig = readFileSync(
|
|
path.join(ROOT, 'scripts/tests/vitest.config.ts'),
|
|
'utf8',
|
|
);
|
|
expect(scriptSuiteConfig).not.toContain(
|
|
"'scripts/tests/install-script.test.js'",
|
|
);
|
|
expect(scriptSuiteConfig).not.toContain(
|
|
"'scripts/tests/opentui-preview-install.test.js'",
|
|
);
|
|
});
|
|
|
|
it('pins the shared Node preflight wiring on the Linux gates', () => {
|
|
const workflow = readFileSync(
|
|
path.join(ROOT, '.github/workflows/ci.yml'),
|
|
'utf8',
|
|
);
|
|
|
|
// The Windows gate and the smoke workflow are pinned above; these three
|
|
// call sites must be pinned too, or a revert to the inline pre-PR script
|
|
// keeps the suite green and only surfaces when a self-hosted machine
|
|
// lacks Node on PATH and runs without the preflight's fail-fast error.
|
|
const nodeCalls = {
|
|
test: getWorkflowStep(
|
|
getWorkflowJob(workflow, 'test'),
|
|
'Use pre-installed Node.js (self-hosted)',
|
|
),
|
|
web_shell_e2e_smoke: getWorkflowStep(
|
|
getWorkflowJob(workflow, 'web_shell_e2e_smoke'),
|
|
'Use pre-installed Node.js (self-hosted)',
|
|
),
|
|
integration_cli: getWorkflowStep(
|
|
getWorkflowJob(workflow, 'integration_cli'),
|
|
'Use pre-installed Node.js (self-hosted)',
|
|
),
|
|
integration_no_ak: getWorkflowStep(
|
|
getWorkflowJob(workflow, 'integration_no_ak'),
|
|
'Use pre-installed Node.js (self-hosted)',
|
|
),
|
|
};
|
|
for (const [jobName, call] of Object.entries(nodeCalls)) {
|
|
expect(call, `${jobName} must use the shared Node preflight`).toContain(
|
|
"uses: './.github/actions/self-hosted-node'",
|
|
);
|
|
}
|
|
expect(nodeCalls.test).toContain(
|
|
"if: \"${{ needs.classify_pr.outputs.skip_ci != 'true' && steps.ci_profile.outputs.ci_profile == 'full' && runner.environment == 'self-hosted' }}\"",
|
|
);
|
|
for (const jobName of ['web_shell_e2e_smoke', 'integration_cli']) {
|
|
expect(nodeCalls[jobName]).toContain(
|
|
'if: "${{ runner.environment == \'self-hosted\' }}"',
|
|
);
|
|
}
|
|
expect(nodeCalls.integration_no_ak).toContain(
|
|
"if: \"${{ steps.ci_profile.outputs.ci_profile == 'full' && runner.environment == 'self-hosted' }}\"",
|
|
);
|
|
});
|
|
|
|
it('installs Playwright system dependencies on hosted runners, and on pool hosts only when missing', () => {
|
|
const workflow = readFileSync(
|
|
path.join(ROOT, '.github/workflows/ci.yml'),
|
|
'utf8',
|
|
);
|
|
const webShellJob = getWorkflowJob(workflow, 'web_shell_e2e_smoke');
|
|
|
|
// The lane still takes its runner from the classifier, so an untrusted
|
|
// fork PR cannot reach a self-hosted host; the architecture override it
|
|
// applies on top is pinned in scripts/tests/ci-platform-lanes.test.js.
|
|
expect(webShellJob).toMatch(
|
|
/runs-on: .*needs\.classify_pr\.outputs\.ubuntu_runner/,
|
|
);
|
|
const hostedInstall = getWorkflowStep(
|
|
webShellJob,
|
|
'Install Playwright Chromium and WebKit (hosted)',
|
|
);
|
|
const selfHostedInstall = getWorkflowStep(
|
|
webShellJob,
|
|
'Install Playwright Chromium and WebKit (self-hosted)',
|
|
);
|
|
|
|
expect(hostedInstall).toContain(
|
|
'node node_modules/playwright/cli.js install --with-deps chromium webkit',
|
|
);
|
|
expect(selfHostedInstall).toContain(
|
|
'node node_modules/playwright/cli.js install chromium webkit',
|
|
);
|
|
expect(selfHostedInstall).not.toContain('install --with-deps chromium');
|
|
// Pool hosts lacked WebKit's libraries: install them once per host, gated
|
|
// on Playwright's own validation, without prompting or racing apt locks.
|
|
expect(selfHostedInstall).toContain(
|
|
'if grep -q \'Host system is missing dependencies\' "${install_log}"; then',
|
|
);
|
|
expect(selfHostedInstall).toContain('DPkg::Lock::Timeout');
|
|
expect(selfHostedInstall).toContain(
|
|
'sudo -n env APT_CONFIG="${apt_config}" "$(command -v node)" node_modules/playwright/cli.js install-deps chromium webkit',
|
|
);
|
|
for (const step of [hostedInstall, selfHostedInstall]) {
|
|
expect(step).toContain(
|
|
"nested_cli='node_modules/@playwright/test/node_modules/playwright/cli.js'",
|
|
);
|
|
expect(step).toContain('node "${nested_cli}" install chromium webkit');
|
|
}
|
|
});
|
|
|
|
it('installs Chromium and WebKit for both Playwright revisions in the nightly browser gate', () => {
|
|
const workflow = readFileSync(
|
|
path.join(ROOT, '.github/workflows/e2e.yml'),
|
|
'utf8',
|
|
);
|
|
const browserJob = getWorkflowJob(workflow, 'web-shell-browser-regression');
|
|
const install = getWorkflowStep(
|
|
browserJob,
|
|
'Install Playwright Chromium and WebKit',
|
|
);
|
|
|
|
expect(install).toContain(
|
|
'node node_modules/playwright/cli.js install --with-deps chromium webkit',
|
|
);
|
|
expect(install).toContain(
|
|
"nested_cli='node_modules/@playwright/test/node_modules/playwright/cli.js'",
|
|
);
|
|
expect(install).toContain('node "${nested_cli}" install chromium webkit');
|
|
});
|
|
});
|
|
|
|
// The 8.3 alias this guard exists for (`C:\Users\RUNNER~1` realpathing to
|
|
// `C:\Users\runneradmin`) cannot be reproduced off Windows, but every
|
|
// decision the guard makes is a comparison between an env value and its
|
|
// realpath — and a symlink reproduces each of those on any platform. Run the
|
|
// real script rather than pinning its text: the case-insensitive comparison
|
|
// below is the whole point of the step, and a substring pin cannot tell a
|
|
// working comparison from a reverted one.
|
|
describe('Windows temp short-alias guard', () => {
|
|
const workflow = readFileSync(
|
|
path.join(ROOT, '.github/workflows/ci.yml'),
|
|
'utf8',
|
|
);
|
|
const step = getWorkflowStep(
|
|
getWorkflowJob(workflow, 'test_windows'),
|
|
'Verify temp paths carry no short alias',
|
|
);
|
|
// The script is single-quoted throughout precisely so this stays a
|
|
// delimiter-safe extraction.
|
|
const match = /node -e "([^"]+)"/.exec(step);
|
|
const script = match?.[1];
|
|
|
|
// Invoked exactly as the workflow does: `node -e <script>`, no extra argv.
|
|
const runGuard = (env) =>
|
|
execFileSync(
|
|
process.execPath,
|
|
['-e', script],
|
|
// Only TEMP/TMP may reach the guard: inheriting the ambient
|
|
// environment would let the host's own temp decide the verdict.
|
|
// stdio 'pipe' captures stderr too, so a failure's message reaches
|
|
// the assertion instead of the test runner's console.
|
|
{ env, encoding: 'utf8', stdio: 'pipe' },
|
|
);
|
|
|
|
it('extracts a runnable script from the workflow', () => {
|
|
expect(script).toBeTruthy();
|
|
});
|
|
|
|
it.runIf(process.platform === 'linux')(
|
|
'accepts, warns, or fails on the three ways an env path can meet its realpath',
|
|
() => {
|
|
const base = realpathSync(
|
|
mkdtempSync(path.join(tmpdir(), 'temp-guard-')),
|
|
);
|
|
try {
|
|
const canonical = path.join(base, 'runneradmin');
|
|
mkdirSync(canonical);
|
|
|
|
// 1. Alias-free: the env value already IS its realpath.
|
|
expect(() =>
|
|
runGuard({ TEMP: canonical, TMP: canonical }),
|
|
).not.toThrow();
|
|
|
|
// 2. Casing-only difference — the regression this guard had to stop
|
|
// producing. On Windows realpath returns the on-disk casing, so this
|
|
// is one directory under one spelling: warn, do not fail the lane.
|
|
const casing = path.join(base, 'RUNNERADMIN');
|
|
symlinkSync(canonical, casing);
|
|
let stdout = '';
|
|
expect(() => {
|
|
stdout = runGuard({ TEMP: casing, TMP: casing });
|
|
}).not.toThrow();
|
|
expect(stdout).toContain('::warning::');
|
|
expect(stdout).toContain('only by casing');
|
|
|
|
// 3. A genuine second spelling, as the 8.3 alias produces: fail.
|
|
const alias = path.join(base, 'RUNNER~1');
|
|
symlinkSync(canonical, alias);
|
|
expect(() => runGuard({ TEMP: alias, TMP: alias })).toThrow(
|
|
/carries a short alias/,
|
|
);
|
|
} finally {
|
|
rmSync(base, { recursive: true, force: true });
|
|
}
|
|
},
|
|
);
|
|
|
|
it.runIf(process.platform === 'linux')(
|
|
'checks each temp key on its own, so the loop cannot drop one',
|
|
() => {
|
|
const base = realpathSync(
|
|
mkdtempSync(path.join(tmpdir(), 'temp-guard-')),
|
|
);
|
|
try {
|
|
const canonical = path.join(base, 'runneradmin');
|
|
mkdirSync(canonical);
|
|
const alias = path.join(base, 'RUNNER~1');
|
|
symlinkSync(canonical, alias);
|
|
|
|
// Every case above hands both keys the same value, so a guard loop
|
|
// narrowed to one key still passes all of them. Only a split pairing
|
|
// pins that the second key is visited at all, and that the failure
|
|
// names it.
|
|
expect(() => runGuard({ TEMP: canonical, TMP: alias })).toThrow(
|
|
/TMP carries a short alias/,
|
|
);
|
|
expect(() => runGuard({ TEMP: alias, TMP: canonical })).toThrow(
|
|
/TEMP carries a short alias/,
|
|
);
|
|
} finally {
|
|
rmSync(base, { recursive: true, force: true });
|
|
}
|
|
},
|
|
);
|
|
|
|
it('fails loudly when temp is unset instead of throwing on undefined', () => {
|
|
// configure-windows-runner and the hosted redirect both set TEMP and TMP,
|
|
// so an unset value means one of them stopped running — a clear message
|
|
// beats realpathSync(undefined)'s TypeError.
|
|
expect(() => runGuard({ TEMP: '', TMP: '' })).toThrow(/TEMP is not set/);
|
|
});
|
|
|
|
it('annotates an unset temp so the failure reaches the check UI', () => {
|
|
// A bare console.error leaves the run reporting only "Process completed
|
|
// with exit code 1". The `::error::` prefix is what GitHub turns into an
|
|
// annotation attached to the check — the same channel the
|
|
// hosted-process-suite step in this workflow already uses.
|
|
let err;
|
|
try {
|
|
runGuard({ TEMP: '', TMP: '' });
|
|
} catch (e) {
|
|
err = e;
|
|
}
|
|
expect(err).toBeDefined();
|
|
expect(err.stderr).toContain('::error::');
|
|
expect(err.stderr).toContain('TEMP is not set');
|
|
});
|
|
|
|
it.runIf(process.platform === 'linux')(
|
|
'annotates a short alias so the failure reaches the check UI',
|
|
() => {
|
|
const base = realpathSync(
|
|
mkdtempSync(path.join(tmpdir(), 'temp-guard-')),
|
|
);
|
|
try {
|
|
const canonical = path.join(base, 'runneradmin');
|
|
mkdirSync(canonical);
|
|
const alias = path.join(base, 'RUNNER~1');
|
|
symlinkSync(canonical, alias);
|
|
|
|
let err;
|
|
try {
|
|
runGuard({ TEMP: alias, TMP: alias });
|
|
} catch (e) {
|
|
err = e;
|
|
}
|
|
expect(err).toBeDefined();
|
|
expect(err.stderr).toContain('::error::');
|
|
expect(err.stderr).toContain('TEMP carries a short alias');
|
|
} finally {
|
|
rmSync(base, { recursive: true, force: true });
|
|
}
|
|
},
|
|
);
|
|
});
|