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.
386 lines
13 KiB
JavaScript
386 lines
13 KiB
JavaScript
/**
|
|
* @license
|
|
* Copyright 2026 Qwen Team
|
|
* SPDX-License-Identifier: Apache-2.0
|
|
*/
|
|
|
|
import { execFileSync } from 'node:child_process';
|
|
import {
|
|
chmodSync,
|
|
copyFileSync,
|
|
existsSync,
|
|
mkdirSync,
|
|
mkdtempSync,
|
|
readFileSync,
|
|
rmSync,
|
|
writeFileSync,
|
|
} from 'node:fs';
|
|
import { tmpdir } from 'node:os';
|
|
import { join } from 'node:path';
|
|
import { describe, expect, it } from 'vitest';
|
|
import {
|
|
changedWorkspaces,
|
|
hashWorkspaces,
|
|
isGlobalInput,
|
|
isTestFile,
|
|
} from '../ci-build-cache.mjs';
|
|
|
|
const workspaces = [
|
|
{ name: 'core', dir: 'packages/core', deps: ['vitest'] },
|
|
{ name: 'cli', dir: 'packages/cli', deps: ['core'] },
|
|
{ name: 'channel', dir: 'packages/channels/base', deps: [] },
|
|
{ name: 'nested', dir: 'packages/channels/base/nested', deps: [] },
|
|
];
|
|
const files = [
|
|
{ path: 'package.json', blob: 'a1' },
|
|
{ path: 'scripts/build_package.js', blob: 'a2' },
|
|
{ path: 'packages/core/src/index.ts', blob: 'b1' },
|
|
{ path: 'packages/cli/src/index.ts', blob: 'c1' },
|
|
{ path: 'packages/channels/base/src/index.ts', blob: 'd1' },
|
|
{ path: 'packages/channels/base/nested/src/index.ts', blob: 'e1' },
|
|
];
|
|
const hashAfter = (path, blob) =>
|
|
hashWorkspaces(
|
|
workspaces,
|
|
files.some((file) => file.path === path)
|
|
? files.map((file) => (file.path === path ? { path, blob } : file))
|
|
: [...files, { path, blob }],
|
|
);
|
|
const changedBy = (path, blob = 'changed') =>
|
|
changedWorkspaces(hashAfter(path, blob), hashWorkspaces(workspaces, files));
|
|
|
|
describe('ci-build-cache', () => {
|
|
it('rebuilds a changed workspace and every workspace depending on it', () => {
|
|
expect(changedBy('packages/core/src/index.ts')).toEqual(['core', 'cli']);
|
|
expect(changedBy('packages/cli/src/index.ts')).toEqual(['cli']);
|
|
});
|
|
|
|
it('gives a nested workspace its own files', () => {
|
|
expect(changedBy('packages/channels/base/nested/src/index.ts')).toEqual([
|
|
'nested',
|
|
]);
|
|
expect(changedBy('packages/channels/base/src/index.ts')).toEqual([
|
|
'channel',
|
|
]);
|
|
});
|
|
|
|
it('rebuilds everything when a shared build input changes', () => {
|
|
expect(changedBy('package.json')).toEqual(workspaces.map((w) => w.name));
|
|
expect(changedBy('scripts/build_package.js')).toEqual(
|
|
workspaces.map((w) => w.name),
|
|
);
|
|
});
|
|
|
|
it('ignores tests and paths no build reads', () => {
|
|
expect(changedBy('packages/core/src/index.test.ts')).toEqual([]);
|
|
expect(changedBy('packages/cli/src/__snapshots__/a.snap')).toEqual([]);
|
|
expect(changedBy('scripts/tests/build.test.js')).toEqual([]);
|
|
expect(changedBy('docs/guide.md')).toEqual([]);
|
|
expect(changedBy('.github/workflows/ci.yml')).toEqual([]);
|
|
});
|
|
|
|
it('rebuilds everything when nothing was stored', () => {
|
|
expect(changedWorkspaces(hashWorkspaces(workspaces, files), null)).toEqual(
|
|
workspaces.map((w) => w.name),
|
|
);
|
|
});
|
|
|
|
it('classifies test files and shared build inputs', () => {
|
|
expect(isTestFile('src/a.test.tsx')).toBe(true);
|
|
expect(isTestFile('src/a.spec.mjs')).toBe(true);
|
|
expect(isTestFile('src/__tests__/helper.ts')).toBe(true);
|
|
expect(isTestFile('src/testing.ts')).toBe(false);
|
|
expect(isGlobalInput('pnpm-lock.yaml')).toBe(true);
|
|
expect(isGlobalInput('patches/ink+7.0.3.patch')).toBe(true);
|
|
expect(isGlobalInput('scripts/copy_files.js')).toBe(true);
|
|
expect(isGlobalInput('scripts/tests/a.test.js')).toBe(false);
|
|
expect(isGlobalInput('docs/a.md')).toBe(false);
|
|
});
|
|
});
|
|
|
|
// The three modes that run in CI call git, pnpm, npm and tar, so they are
|
|
// exercised as the real CLI against a fixture repository with those binaries
|
|
// stubbed. The pure-helper tests above never reach `main()`, so a broken
|
|
// `builtPaths()` filter — the one both `pack` and the `rm -rf` purge use —
|
|
// could take every shard down while the unit suite stayed green.
|
|
const scriptPath = join(import.meta.dirname, '..', 'ci-build-cache.mjs');
|
|
|
|
function makeFixture() {
|
|
const fixtureDir = mkdtempSync(join(tmpdir(), 'ci-build-cache-fixture-'));
|
|
const repo = join(fixtureDir, 'repo');
|
|
const shims = join(repo, '.shims');
|
|
// Keep observations outside the tree that `pack` archives and `build` restores.
|
|
const calls = join(fixtureDir, '.calls');
|
|
const workspacesJson = join(repo, '.workspaces.json');
|
|
|
|
for (const name of ['a', 'b']) {
|
|
const workspace = join(repo, 'packages', name);
|
|
mkdirSync(join(workspace, 'src'), { recursive: true });
|
|
mkdirSync(join(workspace, 'dist'), { recursive: true });
|
|
writeFileSync(
|
|
join(workspace, 'package.json'),
|
|
`{"name":"${name}","version":"1.0.0"}\n`,
|
|
);
|
|
writeFileSync(join(workspace, 'src/index.ts'), 'export {};\n');
|
|
writeFileSync(join(workspace, 'dist/out.js'), `${name} built\n`);
|
|
}
|
|
mkdirSync(join(repo, 'node_modules/x'), { recursive: true });
|
|
mkdirSync(shims, { recursive: true });
|
|
// `.cache/` is deliberately not ignored — CI's cache directory is not
|
|
// ignored either, and an ignored one lands in `builtPaths()` and gets
|
|
// tarred into itself.
|
|
writeFileSync(join(repo, '.gitignore'), 'dist/\nnode_modules/\n');
|
|
writeFileSync(join(repo, 'package.json'), '{"name":"root","private":true}\n');
|
|
writeFileSync(join(repo, 'node_modules/x/index.js'), 'dep\n');
|
|
writeFileSync(
|
|
workspacesJson,
|
|
JSON.stringify(
|
|
['a', 'b'].map((name) => ({
|
|
name,
|
|
path: join(repo, 'packages', name),
|
|
})),
|
|
),
|
|
);
|
|
|
|
const shim = (name, body) => {
|
|
const path = join(shims, name);
|
|
writeFileSync(path, `#!/bin/sh\n${body}\n`);
|
|
chmodSync(path, 0o755);
|
|
};
|
|
shim('npm', 'printf \'npm %s\\n\' "$*" >> "$CALLS"');
|
|
shim('node', 'printf \'node %s\\n\' "$*" >> "$CALLS"');
|
|
shim(
|
|
'corepack',
|
|
'printf \'corepack %s\\n\' "$*" >> "$CALLS"\n' +
|
|
'case "$*" in *"ls -r"*) cat "$WORKSPACES" ;; esac',
|
|
);
|
|
|
|
execFileSync('git', ['init', '-q', '.'], { cwd: repo });
|
|
execFileSync('git', ['add', '-A'], { cwd: repo });
|
|
|
|
const env = {
|
|
...process.env,
|
|
CI_BUILD_CACHE_ROOT: repo,
|
|
PATH: `${shims}:${process.env.PATH}`,
|
|
CALLS: calls,
|
|
WORKSPACES: workspacesJson,
|
|
};
|
|
const run = (...argv) => {
|
|
writeFileSync(calls, '');
|
|
const stdout = execFileSync(process.execPath, [scriptPath, ...argv], {
|
|
cwd: repo,
|
|
env,
|
|
encoding: 'utf8',
|
|
});
|
|
return { stdout, calls: readFileSync(calls, 'utf8') };
|
|
};
|
|
return {
|
|
repo,
|
|
env,
|
|
calls,
|
|
run,
|
|
cleanup: () => rmSync(fixtureDir, { recursive: true, force: true }),
|
|
};
|
|
}
|
|
|
|
describe('ci-build-cache CLI', () => {
|
|
it('packs the built tree without node_modules', () => {
|
|
const fixture = makeFixture();
|
|
try {
|
|
const cache = join(fixture.repo, '.cache');
|
|
fixture.run('pack', cache);
|
|
expect(existsSync(join(cache, 'hashes.json'))).toBe(true);
|
|
const listed = execFileSync('tar', ['-tzf', join(cache, 'tree.tgz')], {
|
|
encoding: 'utf8',
|
|
})
|
|
.split('\n')
|
|
.filter(Boolean);
|
|
expect(listed).toContain('packages/a/dist/');
|
|
// The filter feeds both this archive and the `rm -rf` purge of a changed
|
|
// workspace: if it stops matching, the cache entry carries every
|
|
// node_modules tree and the incremental build deletes one it still needs.
|
|
expect(listed.some((path) => path.startsWith('node_modules'))).toBe(
|
|
false,
|
|
);
|
|
} finally {
|
|
fixture.cleanup();
|
|
}
|
|
});
|
|
|
|
it('restores a cache hit, rebuilds nothing, and still runs the build preflight', () => {
|
|
const fixture = makeFixture();
|
|
try {
|
|
const cache = join(fixture.repo, '.cache');
|
|
fixture.run('pack', cache);
|
|
rmSync(join(fixture.repo, 'packages/a/dist'), {
|
|
recursive: true,
|
|
force: true,
|
|
});
|
|
|
|
const { calls } = fixture.run('build', cache);
|
|
expect(calls).toContain('node scripts/build.js --preflight');
|
|
expect(calls).toContain('npm run generate');
|
|
expect(calls).toContain('npm run bundle');
|
|
// The cached branch rebuilds only changed workspaces; `npm run build`
|
|
// would rebuild every one of them.
|
|
expect(calls).not.toContain('npm run build');
|
|
expect(calls).not.toMatch(/corepack pnpm -r .*run build/);
|
|
// The preflight guards the ink patch packages/cli typechecks against,
|
|
// and this branch never reaches scripts/build.js otherwise.
|
|
expect(calls.indexOf('node scripts/build.js --preflight')).toBeLessThan(
|
|
calls.indexOf('npm run generate'),
|
|
);
|
|
expect(existsSync(join(fixture.repo, 'packages/a/dist/out.js'))).toBe(
|
|
true,
|
|
);
|
|
} finally {
|
|
fixture.cleanup();
|
|
}
|
|
});
|
|
|
|
it('builds everything when no cache was stored', () => {
|
|
const fixture = makeFixture();
|
|
try {
|
|
const { calls } = fixture.run('build', join(fixture.repo, '.missing'));
|
|
expect(calls).toContain('npm run build');
|
|
expect(calls).toContain('npm run bundle');
|
|
} finally {
|
|
fixture.cleanup();
|
|
}
|
|
});
|
|
|
|
it('rebuilds and purges only the changed workspace on a cache hit', () => {
|
|
const fixture = makeFixture();
|
|
try {
|
|
const cache = join(fixture.repo, '.cache');
|
|
fixture.run('pack', cache);
|
|
writeFileSync(
|
|
join(fixture.repo, 'packages/a/src/index.ts'),
|
|
'export {}; // changed\n',
|
|
);
|
|
// The hash reads the index, so the edit counts only once staged.
|
|
execFileSync('git', ['add', '-A'], { cwd: fixture.repo });
|
|
|
|
const { calls } = fixture.run('build', cache);
|
|
expect(calls).toMatch(
|
|
/corepack pnpm -r --filter \{\.\/packages\/a\} run build/,
|
|
);
|
|
expect(calls).not.toContain('npm run build');
|
|
// `stale` purges the changed workspace before rebuilding it and leaves
|
|
// the untouched one alone. Widening builtPaths(dirs) to builtPaths()
|
|
// deletes every restored workspace's dist, and eight shards then run
|
|
// against a tree missing the outputs the cache restored.
|
|
expect(existsSync(join(fixture.repo, 'packages/a/dist/out.js'))).toBe(
|
|
false,
|
|
);
|
|
expect(existsSync(join(fixture.repo, 'packages/b/dist/out.js'))).toBe(
|
|
true,
|
|
);
|
|
} finally {
|
|
fixture.cleanup();
|
|
}
|
|
});
|
|
|
|
it('runs the build preflight on demand and nothing else', () => {
|
|
const fixture = makeFixture();
|
|
try {
|
|
mkdirSync(join(fixture.repo, 'scripts'), { recursive: true });
|
|
copyFileSync(
|
|
join(import.meta.dirname, '..', 'build.js'),
|
|
join(fixture.repo, 'scripts', 'build.js'),
|
|
);
|
|
mkdirSync(join(fixture.repo, 'node_modules/ink/build'), {
|
|
recursive: true,
|
|
});
|
|
writeFileSync(
|
|
join(fixture.repo, 'node_modules/ink/build/frame-controller.d.ts'),
|
|
'export {};\n',
|
|
);
|
|
writeFileSync(fixture.calls, '');
|
|
|
|
// The cached branch of ci-build-cache.mjs depends on this exact
|
|
// contract: exit 0 with no generate, workspace build or schema step.
|
|
// Renaming the flag or dropping its early exit falls through to the
|
|
// full build, which records those calls here.
|
|
execFileSync(
|
|
process.execPath,
|
|
[join(fixture.repo, 'scripts', 'build.js'), '--preflight'],
|
|
{ cwd: fixture.repo, env: fixture.env, encoding: 'utf8' },
|
|
);
|
|
expect(readFileSync(fixture.calls, 'utf8')).toBe('');
|
|
} finally {
|
|
fixture.cleanup();
|
|
}
|
|
});
|
|
|
|
it('prints one key per tracked tree', () => {
|
|
const fixture = makeFixture();
|
|
try {
|
|
const first = fixture.run('key').stdout.trim();
|
|
expect(first).toMatch(/^[0-9a-f]{64}$/);
|
|
expect(fixture.run('key').stdout.trim()).toBe(first);
|
|
// The key reads the index, so the fixture restages after the edit.
|
|
writeFileSync(
|
|
join(fixture.repo, 'packages/a/src/index.ts'),
|
|
'export {}; //x\n',
|
|
);
|
|
execFileSync('git', ['add', '-A'], { cwd: fixture.repo });
|
|
expect(fixture.run('key').stdout.trim()).not.toBe(first);
|
|
} finally {
|
|
fixture.cleanup();
|
|
}
|
|
});
|
|
|
|
it('keys a commit exactly as the worktree keyed it at that commit', () => {
|
|
// A pull request restores the build its base saved: `key --at <base>`
|
|
// has to reproduce the key the base's own run computed from its index.
|
|
const fixture = makeFixture();
|
|
const git = (...args) =>
|
|
execFileSync(
|
|
'git',
|
|
['-c', 'user.name=t', '-c', 'user.email=t@t', ...args],
|
|
{ cwd: fixture.repo, encoding: 'utf8' },
|
|
).trim();
|
|
try {
|
|
git('commit', '-qm', 'base');
|
|
const base = git('rev-parse', 'HEAD');
|
|
const baseKey = fixture.run('key').stdout.trim();
|
|
expect(fixture.run('key', '--at', base).stdout.trim()).toBe(baseKey);
|
|
|
|
writeFileSync(
|
|
join(fixture.repo, 'packages/b/src/index.ts'),
|
|
'export const b = 1;\n',
|
|
);
|
|
git('add', '-A');
|
|
git('commit', '-qm', 'head');
|
|
expect(fixture.run('key').stdout.trim()).not.toBe(baseKey);
|
|
expect(fixture.run('key', '--at', base).stdout.trim()).toBe(baseKey);
|
|
expect(fixture.run('key', '--at', 'HEAD').stdout.trim()).toBe(
|
|
fixture.run('key').stdout.trim(),
|
|
);
|
|
} finally {
|
|
fixture.cleanup();
|
|
}
|
|
});
|
|
|
|
it('rejects a key request without a commit after --at', () => {
|
|
const fixture = makeFixture();
|
|
try {
|
|
expect(() => fixture.run('key', '--at')).toThrow();
|
|
expect(() => fixture.run('key', 'HEAD')).toThrow();
|
|
} finally {
|
|
fixture.cleanup();
|
|
}
|
|
});
|
|
|
|
it('leaves an unbundled tree alone when recopying host assets', () => {
|
|
const fixture = makeFixture();
|
|
try {
|
|
fixture.run('host-assets');
|
|
expect(existsSync(join(fixture.repo, 'dist'))).toBe(false);
|
|
} finally {
|
|
fixture.cleanup();
|
|
}
|
|
});
|
|
});
|