mirror of
https://github.com/QwenLM/qwen-code.git
synced 2026-07-09 17:19:02 +00:00
* perf(ci): optimize autofix pipeline — fast-track, skip duplicate build, scoped tests (#6196) Three optimizations to reduce autofix wall-clock from ~48 min to ~28-35 min: 1. Fast-track decision: skip LLM assessment for trusted triggers (workflow_dispatch with explicit issue, issues:labeled event). 2. Skip duplicate build: set QWEN_SKIP_PREPARE=1 so npm ci does not re-run the prepare hook that builds+bundles before the explicit build step. 3. Scoped verification tests: run only changed-file tests via --changed instead of full per-package suites. Full regression is covered by regular CI on the PR. Also hardens live test gating (require QWEN_CODE_RUN_LIVE_TESTS=1) and increases crawler test timeout to 30s. * refactor(ci): simplify verification gate package discovery Replace 30-line inline Node script with the original one-liner `grep -oE '^packages/[^/]+'`. The Node script checked for package.json existence and test scripts, but `--if-present` already handles missing test scripts and all workspace dirs have package.json. Addresses design-review item #7. * fix(ci): tighten autofix changed-test gate
463 lines
14 KiB
JavaScript
463 lines
14 KiB
JavaScript
/**
|
|
* @license
|
|
* Copyright 2026 Qwen Team
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import {
|
|
chmodSync,
|
|
mkdtempSync,
|
|
readFileSync,
|
|
rmSync,
|
|
writeFileSync,
|
|
} from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import path from 'node:path';
|
|
import { spawnSync } from 'node:child_process';
|
|
import { fileURLToPath } from 'node:url';
|
|
import { describe, expect, it } from 'vitest';
|
|
|
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
const root = path.resolve(__dirname, '../..');
|
|
|
|
function readPackageJson() {
|
|
return JSON.parse(readFileSync(path.join(root, 'package.json'), 'utf8'));
|
|
}
|
|
|
|
function readWorkflow(relativePath) {
|
|
return readFileSync(path.join(root, relativePath), 'utf8');
|
|
}
|
|
|
|
function getWorkflowJob(workflow, jobName) {
|
|
const marker = ` ${jobName}:`;
|
|
const start = workflow.indexOf(marker);
|
|
expect(start).toBeGreaterThanOrEqual(0);
|
|
|
|
const afterMarker = workflow.slice(start + marker.length);
|
|
const nextJob = afterMarker.match(/\n {2}[a-zA-Z0-9_-]+:\n/);
|
|
|
|
return workflow.slice(
|
|
start,
|
|
nextJob ? start + marker.length + nextJob.index : undefined,
|
|
);
|
|
}
|
|
|
|
function getWorkflowStep(job, stepName) {
|
|
const marker = ` - name: '${stepName}'`;
|
|
const start = job.indexOf(marker);
|
|
expect(start).toBeGreaterThanOrEqual(0);
|
|
|
|
const afterMarker = job.slice(start + marker.length);
|
|
const nextStep = afterMarker.match(/\n {6}- name: /);
|
|
|
|
return job.slice(
|
|
start,
|
|
nextStep ? start + marker.length + nextStep.index : undefined,
|
|
);
|
|
}
|
|
|
|
describe('package scripts', () => {
|
|
it('keeps the serve fast-path bundle check outside unit test scripts', () => {
|
|
const packageJson = readPackageJson();
|
|
|
|
expect(packageJson.scripts['test:ci']).not.toContain(
|
|
'npm run check:serve-fast-path-bundle',
|
|
);
|
|
expect(packageJson.scripts.preflight).toContain(
|
|
'npm run check:serve-fast-path-bundle',
|
|
);
|
|
});
|
|
|
|
it('defines a release test script that disables workspace coverage', () => {
|
|
const packageJson = readPackageJson();
|
|
|
|
expect(packageJson.scripts['test:release']).toBe(
|
|
[
|
|
'cross-env NODE_OPTIONS="--max-old-space-size=3072"',
|
|
'npm run test:ci --workspaces --if-present --parallel -- --coverage.enabled=false',
|
|
'&& npm run test:scripts',
|
|
].join(' '),
|
|
);
|
|
|
|
const vscodePackageJson = JSON.parse(
|
|
readFileSync(
|
|
path.join(root, 'packages/vscode-ide-companion/package.json'),
|
|
'utf8',
|
|
),
|
|
);
|
|
expect(vscodePackageJson.scripts['test:ci']).toContain('--coverage');
|
|
});
|
|
|
|
it('can skip root prepare work for CI installs that build explicitly', () => {
|
|
const packageJson = readPackageJson();
|
|
|
|
expect(packageJson.scripts.prepare).toBe('node scripts/prepare.js');
|
|
|
|
const result = spawnSync(
|
|
process.execPath,
|
|
[path.join(root, 'scripts/prepare.js')],
|
|
{
|
|
cwd: root,
|
|
encoding: 'utf8',
|
|
env: {
|
|
...process.env,
|
|
QWEN_SKIP_PREPARE: '1',
|
|
},
|
|
},
|
|
);
|
|
|
|
expect(result.status).toBe(0);
|
|
expect(result.stdout).toContain('Skipping prepare');
|
|
});
|
|
|
|
it('runs prepare steps in order when CI does not skip prepare', () => {
|
|
const binDir = mkdtempSync(path.join(tmpdir(), 'qwen-prepare-bin-'));
|
|
const logFile = path.join(binDir, 'commands.log');
|
|
|
|
try {
|
|
if (process.platform === 'win32') {
|
|
writeFileSync(
|
|
path.join(binDir, 'husky.cmd'),
|
|
'@echo husky >> "%PREPARE_LOG_FILE%"\r\n',
|
|
);
|
|
writeFileSync(
|
|
path.join(binDir, 'npm.cmd'),
|
|
'@echo npm %* >> "%PREPARE_LOG_FILE%"\r\n',
|
|
);
|
|
} else {
|
|
writeFileSync(
|
|
path.join(binDir, 'husky'),
|
|
'#!/bin/sh\necho husky >> "$PREPARE_LOG_FILE"\n',
|
|
);
|
|
writeFileSync(
|
|
path.join(binDir, 'npm'),
|
|
'#!/bin/sh\necho "npm $*" >> "$PREPARE_LOG_FILE"\n',
|
|
);
|
|
chmodSync(path.join(binDir, 'husky'), 0o755);
|
|
chmodSync(path.join(binDir, 'npm'), 0o755);
|
|
}
|
|
|
|
const result = spawnSync(
|
|
process.execPath,
|
|
[path.join(root, 'scripts/prepare.js')],
|
|
{
|
|
cwd: root,
|
|
encoding: 'utf8',
|
|
env: {
|
|
...process.env,
|
|
PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ''}`,
|
|
PREPARE_LOG_FILE: logFile,
|
|
QWEN_SKIP_PREPARE: '',
|
|
},
|
|
},
|
|
);
|
|
|
|
expect(result.status).toBe(0);
|
|
expect(readFileSync(logFile, 'utf8').trim().split(/\r?\n/)).toEqual([
|
|
'husky',
|
|
'npm run build',
|
|
'npm run bundle',
|
|
]);
|
|
} finally {
|
|
rmSync(binDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('exits when a prepare step fails', () => {
|
|
const binDir = mkdtempSync(path.join(tmpdir(), 'qwen-prepare-fail-'));
|
|
const logFile = path.join(binDir, 'commands.log');
|
|
writeFileSync(logFile, '');
|
|
|
|
try {
|
|
if (process.platform === 'win32') {
|
|
writeFileSync(path.join(binDir, 'husky.cmd'), '@exit /b 7\r\n');
|
|
writeFileSync(
|
|
path.join(binDir, 'npm.cmd'),
|
|
'@echo npm %* >> "%PREPARE_LOG_FILE%"\r\n',
|
|
);
|
|
} else {
|
|
writeFileSync(path.join(binDir, 'husky'), '#!/bin/sh\nexit 7\n');
|
|
writeFileSync(
|
|
path.join(binDir, 'npm'),
|
|
'#!/bin/sh\necho "npm $*" >> "$PREPARE_LOG_FILE"\n',
|
|
);
|
|
chmodSync(path.join(binDir, 'husky'), 0o755);
|
|
chmodSync(path.join(binDir, 'npm'), 0o755);
|
|
}
|
|
|
|
const result = spawnSync(
|
|
process.execPath,
|
|
[path.join(root, 'scripts/prepare.js')],
|
|
{
|
|
cwd: root,
|
|
encoding: 'utf8',
|
|
env: {
|
|
...process.env,
|
|
PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ''}`,
|
|
PREPARE_LOG_FILE: logFile,
|
|
QWEN_SKIP_PREPARE: '',
|
|
},
|
|
},
|
|
);
|
|
|
|
expect(result.status).toBe(7);
|
|
expect(result.stderr).toContain('prepare: husky exited with status 7');
|
|
expect(readFileSync(logFile, 'utf8')).toBe('');
|
|
} finally {
|
|
rmSync(binDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it('reports the failing prepare step after earlier steps succeed', () => {
|
|
const binDir = mkdtempSync(path.join(tmpdir(), 'qwen-prepare-late-fail-'));
|
|
const logFile = path.join(binDir, 'commands.log');
|
|
writeFileSync(logFile, '');
|
|
|
|
try {
|
|
if (process.platform === 'win32') {
|
|
writeFileSync(
|
|
path.join(binDir, 'husky.cmd'),
|
|
'@echo husky >> "%PREPARE_LOG_FILE%"\r\n',
|
|
);
|
|
writeFileSync(
|
|
path.join(binDir, 'npm.cmd'),
|
|
[
|
|
'@echo npm %* >> "%PREPARE_LOG_FILE%"',
|
|
'@if "%1 %2"=="run build" exit /b 7',
|
|
'@exit /b 0',
|
|
'',
|
|
].join('\r\n'),
|
|
);
|
|
} else {
|
|
writeFileSync(
|
|
path.join(binDir, 'husky'),
|
|
'#!/bin/sh\necho husky >> "$PREPARE_LOG_FILE"\n',
|
|
);
|
|
writeFileSync(
|
|
path.join(binDir, 'npm'),
|
|
[
|
|
'#!/bin/sh',
|
|
'echo "npm $*" >> "$PREPARE_LOG_FILE"',
|
|
'if [ "$1 $2" = "run build" ]; then exit 7; fi',
|
|
'',
|
|
].join('\n'),
|
|
);
|
|
chmodSync(path.join(binDir, 'husky'), 0o755);
|
|
chmodSync(path.join(binDir, 'npm'), 0o755);
|
|
}
|
|
|
|
const result = spawnSync(
|
|
process.execPath,
|
|
[path.join(root, 'scripts/prepare.js')],
|
|
{
|
|
cwd: root,
|
|
encoding: 'utf8',
|
|
env: {
|
|
...process.env,
|
|
PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ''}`,
|
|
PREPARE_LOG_FILE: logFile,
|
|
QWEN_SKIP_PREPARE: '',
|
|
},
|
|
},
|
|
);
|
|
|
|
expect(result.status).toBe(7);
|
|
expect(result.stderr).toContain(
|
|
'prepare: npm run build exited with status 7',
|
|
);
|
|
expect(readFileSync(logFile, 'utf8').trim().split(/\r?\n/)).toEqual([
|
|
'husky',
|
|
'npm run build',
|
|
]);
|
|
} finally {
|
|
rmSync(binDir, { recursive: true, force: true });
|
|
}
|
|
});
|
|
|
|
it.skipIf(process.platform === 'win32')(
|
|
'reports when a prepare command is killed by a signal',
|
|
() => {
|
|
const binDir = mkdtempSync(path.join(tmpdir(), 'qwen-prepare-signal-'));
|
|
|
|
try {
|
|
writeFileSync(path.join(binDir, 'husky'), '#!/bin/sh\nkill -TERM $$\n');
|
|
writeFileSync(path.join(binDir, 'npm'), '#!/bin/sh\nexit 0\n');
|
|
chmodSync(path.join(binDir, 'husky'), 0o755);
|
|
chmodSync(path.join(binDir, 'npm'), 0o755);
|
|
|
|
const result = spawnSync(
|
|
process.execPath,
|
|
[path.join(root, 'scripts/prepare.js')],
|
|
{
|
|
cwd: root,
|
|
encoding: 'utf8',
|
|
env: {
|
|
...process.env,
|
|
PATH: `${binDir}${path.delimiter}${process.env.PATH ?? ''}`,
|
|
QWEN_SKIP_PREPARE: '',
|
|
},
|
|
},
|
|
);
|
|
|
|
expect(result.status).toBe(1);
|
|
expect(result.stderr).toContain(
|
|
'prepare: husky killed by signal SIGTERM',
|
|
);
|
|
} finally {
|
|
rmSync(binDir, { recursive: true, force: true });
|
|
}
|
|
},
|
|
);
|
|
|
|
it.skipIf(process.platform === 'win32')(
|
|
'reports when a prepare command cannot be spawned',
|
|
() => {
|
|
const missingBinDir = mkdtempSync(
|
|
path.join(tmpdir(), 'qwen-prepare-missing-bin-'),
|
|
);
|
|
|
|
try {
|
|
const result = spawnSync(
|
|
process.execPath,
|
|
[path.join(root, 'scripts/prepare.js')],
|
|
{
|
|
cwd: root,
|
|
encoding: 'utf8',
|
|
env: {
|
|
...process.env,
|
|
PATH: missingBinDir,
|
|
QWEN_SKIP_PREPARE: '',
|
|
},
|
|
},
|
|
);
|
|
|
|
expect(result.status).toBe(1);
|
|
expect(result.stderr).toContain('prepare: husky failed:');
|
|
} finally {
|
|
rmSync(missingBinDir, { recursive: true, force: true });
|
|
}
|
|
},
|
|
);
|
|
|
|
it('wires release quality checks to fast explicit validation steps', () => {
|
|
const workflow = readWorkflow('.github/workflows/release.yml');
|
|
const qualityJob = getWorkflowJob(workflow, 'quality');
|
|
const buildStep = getWorkflowStep(qualityJob, 'Build Project');
|
|
const serveFastPathStep = getWorkflowStep(
|
|
qualityJob,
|
|
'Check Serve Fast Path Bundle',
|
|
);
|
|
const workspaceTestStep = getWorkflowStep(
|
|
qualityJob,
|
|
'Run Workspace Tests',
|
|
);
|
|
|
|
expect(qualityJob).toContain("name: 'Check Serve Fast Path Bundle'");
|
|
expect(qualityJob).toContain('npm run check:serve-fast-path-bundle');
|
|
expect(qualityJob.indexOf(serveFastPathStep)).toBeLessThan(
|
|
qualityJob.indexOf(buildStep),
|
|
);
|
|
expect(workspaceTestStep).toContain('npm run test:release');
|
|
expect(workspaceTestStep).not.toContain('npm run test:ci');
|
|
});
|
|
|
|
it('skips release install-time prepare and builds before publish bundling', () => {
|
|
const workflow = readWorkflow('.github/workflows/release.yml');
|
|
const installSteps =
|
|
workflow.match(
|
|
/ {6}- name: 'Install Dependencies'[\s\S]*? {10}npm ci --no-audit --progress=false/g,
|
|
) || [];
|
|
|
|
expect(installSteps.length).toBeGreaterThanOrEqual(5);
|
|
for (const installStep of installSteps) {
|
|
expect(installStep).toContain("QWEN_SKIP_PREPARE: '1'");
|
|
}
|
|
|
|
for (const jobName of ['integration_none', 'integration_docker']) {
|
|
const integrationJob = getWorkflowJob(workflow, jobName);
|
|
const buildStep = getWorkflowStep(integrationJob, 'Build Bundle');
|
|
expect(buildStep).toContain('npm run build\n npm run bundle');
|
|
}
|
|
|
|
const publishJob = getWorkflowJob(workflow, 'publish');
|
|
const gitConfigStep = getWorkflowStep(publishJob, 'Configure Git User');
|
|
const commitStep = getWorkflowStep(
|
|
publishJob,
|
|
'Commit and Conditionally Push package versions',
|
|
);
|
|
const buildStep = getWorkflowStep(
|
|
publishJob,
|
|
'Build Bundle and Prepare Package',
|
|
);
|
|
|
|
expect(gitConfigStep).toContain('git config core.hooksPath .husky');
|
|
expect(publishJob.indexOf(gitConfigStep)).toBeLessThan(
|
|
publishJob.indexOf(commitStep),
|
|
);
|
|
expect(buildStep).toContain('npm run build\n npm run bundle');
|
|
});
|
|
|
|
it('fast-tracks trusted autofix issue triggers before LLM assessment', () => {
|
|
const workflow = readWorkflow('.github/workflows/qwen-autofix.yml');
|
|
const issueJob = getWorkflowJob(workflow, 'issue-autofix');
|
|
const scanStep = getWorkflowStep(issueJob, 'Find candidate issues');
|
|
const fastTrackStep = getWorkflowStep(issueJob, 'Fast-track decision');
|
|
const assessStep = getWorkflowStep(issueJob, 'Assess candidates');
|
|
|
|
expect(issueJob.indexOf(scanStep)).toBeLessThan(
|
|
issueJob.indexOf(fastTrackStep),
|
|
);
|
|
expect(issueJob.indexOf(fastTrackStep)).toBeLessThan(
|
|
issueJob.indexOf(assessStep),
|
|
);
|
|
expect(fastTrackStep).toContain("id: 'fasttrack'");
|
|
expect(fastTrackStep).toContain('FAST_TRACK=false');
|
|
expect(fastTrackStep).toContain('FAST_TRACK=true');
|
|
expect(fastTrackStep).toContain('fast_tracked=false');
|
|
expect(fastTrackStep).toContain('-n "${FORCED_ISSUE}"');
|
|
expect(fastTrackStep).toContain(
|
|
'Fast-tracked: trusted trigger bypasses LLM assessment.',
|
|
);
|
|
expect(assessStep).toContain(
|
|
"steps.fasttrack.outputs.fast_tracked != 'true'",
|
|
);
|
|
});
|
|
|
|
it('skips autofix install-time prepare without disabling dependency scripts', () => {
|
|
const workflow = readWorkflow('.github/workflows/qwen-autofix.yml');
|
|
|
|
for (const jobName of ['issue-autofix', 'review-address']) {
|
|
const job = getWorkflowJob(workflow, jobName);
|
|
const installStep = getWorkflowStep(
|
|
job,
|
|
'Install dependencies and build',
|
|
);
|
|
|
|
expect(installStep).toContain("QWEN_SKIP_PREPARE: '1'");
|
|
expect(installStep).toContain(
|
|
'npm ci --prefer-offline --no-audit --progress=false',
|
|
);
|
|
expect(installStep).toContain('git config core.hooksPath .husky');
|
|
expect(installStep).not.toContain('--ignore-scripts');
|
|
}
|
|
});
|
|
|
|
it('runs changed autofix tests instead of full touched-package suites', () => {
|
|
const workflow = readWorkflow('.github/workflows/qwen-autofix.yml');
|
|
|
|
for (const jobName of ['issue-autofix', 'review-address']) {
|
|
const job = getWorkflowJob(workflow, jobName);
|
|
const verifyStep = getWorkflowStep(job, 'Verification gate');
|
|
|
|
expect(verifyStep).toContain(
|
|
'npm run test --workspace "${p}" --if-present -- --changed origin/main --passWithNoTests',
|
|
);
|
|
expect(verifyStep).toContain("grep -oE '^packages/[^/]+'");
|
|
expect(verifyStep).toContain('pkg.scripts?.test');
|
|
expect(verifyStep).toContain('!= *vitest*');
|
|
expect(verifyStep).not.toContain(
|
|
'npm run test --workspace "${p}" --if-present\n',
|
|
);
|
|
}
|
|
});
|
|
});
|