Files
qwen-code/scripts/tests/workspaces.test.js
T
Shaojin Wen 1df5031e70 perf(ci): overlap web-shell and standalone tests with the CLI suite (#15438)
* perf(ci): overlap web-shell and standalone tests with the CLI suite

The Linux Test job ran every workspace suite, the standalone runtime
regression and scripts/tests one after another. The CLI suite keeps its
workers busy for 13-20 minutes, while web-shell's suite spends most of its
run on App.test.tsx alone and the standalone regression starts one
runtime process at a time, so those two added their mostly idle minutes
straight to the job.

Run them in a side lane beside the other workspaces instead.
.github/scripts/ci/run-unit-test-lanes.sh starts web-shell and the
standalone regression in the background, runs every other workspace's
test:ci (listed with npm pkg get, in npm's workspace order) and then
scripts/tests, and replays the side lane's log after the main lane so
the two never interleave. The step runs the script under the same
setpriv wrapper, so every test command still runs without the DAC
override capabilities.

test:scripts now reaches its Vitest run through a new test:scripts:unit
script with a trailing --, so the main lane can run it without the
standalone regression while test:ci, test:ci:workspaces and test:scripts
run what they ran before.

* fix(ci): name the cause when the lane script cannot start a lane

- Fail with an ::error:: line when mktemp cannot create the side lane's
  log file, instead of running the main lane alone and dying in tail.
- Check the exit status of `npm pkg get` and of the parser. A failing
  `npm pkg get --json` prints an error object, which used to parse as an
  empty listing and report "Found no workspaces".
- Note in the header that the replay puts side-lane failures after the
  main lane's in main-failure-signature.mjs's title and search markers.
- Lane test: clear the caller's NODE_OPTIONS, assert the main lane's
  output is present before checking its order, and cover the failed
  listing and the missing temp dir.
- Cross-check npm's workspace resolution against the selector in
  scripts/workspaces.js.

* fix(ci): halve web-shell's worker cap beside the main lane

Each Vitest run applies VITEST_MAX_FORKS / VITEST_MAX_THREADS on its own,
so running two lanes at once doubled the step's worker cap, and on the
hosted runners, where no cap is set, put two all-CPUs pools on 4 vCPU.
The lane script now runs web-shell at half the cap, at least 1, and at 2
where the cap is unset; the main lane keeps the full cap.

- Refuse a workspace name with whitespace in it: the main lane's
  arguments are read one per line, so a line break would split a name
  into other arguments, flags among them.
- Lane test: web-shell and the main lane's suites now wait for each
  other, so a side lane serialized before or after the main lane fails;
  record each call's worker caps; run the script against npm's real
  listing and compare its main lane with the selector the release pins
  use; cover a name with a line break in it.
- ci.yml: the standalone runtime cache comment names the lane script's
  side lane, not test:scripts, as what runs the regression in this job.
- ci-variables.md: say the cap applies per Vitest run and what the
  two-lane step runs at its peak.
- release-workflow.test.js: follow test:scripts through
  test:scripts:unit to vitest; #15102 pinned the shape before the split.
2026-10-25 10:05:56 +00:00

166 lines
5.0 KiB
JavaScript

/**
* @license
* Copyright 2026 Qwen Team
* SPDX-License-Identifier: Apache-2.0
*/
import { spawnSync } from 'node:child_process';
import {
mkdirSync,
mkdtempSync,
readFileSync,
rmSync,
writeFileSync,
} from 'node:fs';
import { tmpdir } from 'node:os';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { afterEach, describe, expect, it } from 'vitest';
import {
getTestCiWorkspacePackageJsonPaths,
getWorkspacePackageJsonPaths,
} from '../workspaces.js';
describe('workspace helpers', () => {
const tempDirs = [];
afterEach(() => {
for (const tempDir of tempDirs.splice(0)) {
rmSync(tempDir, { force: true, recursive: true });
}
});
it('honors negated workspace patterns', () => {
const root = mkdtempSync(path.join(tmpdir(), 'qwen-workspaces-'));
tempDirs.push(root);
for (const packagePath of [
'packages/cli/package.json',
'packages/core/package.json',
'packages/desktop/package.json',
'packages/channels/base/package.json',
]) {
writeFile(root, packagePath, '{}\n');
}
expect(
getWorkspacePackageJsonPaths(root, [
'packages/*',
'packages/channels/base',
'!packages/desktop',
]),
).toEqual([
'packages/channels/base/package.json',
'packages/cli/package.json',
'packages/core/package.json',
]);
});
it('normalizes Windows-style workspace patterns', () => {
const root = mkdtempSync(path.join(tmpdir(), 'qwen-workspaces-'));
tempDirs.push(root);
for (const packagePath of [
'packages/cli/package.json',
'packages/core/package.json',
'packages/desktop/package.json',
'packages/channels/base/package.json',
]) {
writeFile(root, packagePath, '{}\n');
}
expect(
getWorkspacePackageJsonPaths(root, [
'packages\\*',
'packages\\channels\\base',
'!packages\\desktop',
]),
).toEqual([
'packages/channels/base/package.json',
'packages/cli/package.json',
'packages/core/package.json',
]);
});
it('returns exactly the package.json paths of workspaces with a test:ci script', () => {
const root = mkdtempSync(path.join(tmpdir(), 'qwen-workspaces-'));
tempDirs.push(root);
writeFile(
root,
'package.json',
'{"workspaces": ["integrations/*", "packages/*", ' +
'"packages/channels/nested-ci", "packages/channels/nested-no-ci"]}\n',
);
// Both repo layouts are witness-bearing: the flat integrations/* layout
// and the nested packages/channels/* layout. The nested members need
// explicit root-list entries because packages/* does not match nested
// directories — exactly why a filter mutation that drops the subtree
// (say `!path.startsWith('packages/channels/')`) would stay invisible
// to every consumer that derives its set from the selector. This
// exact-set pin is what catches it.
writeFile(
root,
'integrations/with-ci/package.json',
'{"scripts": {"test:ci": "vitest run"}}\n',
);
writeFile(
root,
'packages/without-ci/package.json',
'{"scripts": {"test": "vitest run"}}\n',
);
writeFile(
root,
'packages/channels/nested-ci/package.json',
'{"scripts": {"test:ci": "vitest run"}}\n',
);
writeFile(
root,
'packages/channels/nested-no-ci/package.json',
'{"scripts": {"test": "vitest run"}}\n',
);
expect(getTestCiWorkspacePackageJsonPaths(root)).toEqual([
'integrations/with-ci/package.json',
'packages/channels/nested-ci/package.json',
]);
});
it('selects the workspaces npm runs test:ci in', () => {
// npm resolves the workspaces itself for `npm run test:ci:workspaces`
// and for the Linux test job's lane script
// (.github/scripts/ci/run-unit-test-lanes.sh), which lists them with
// `npm pkg get`. The pins that gate on this selector's set must name the
// suites those runs start.
const root = path.resolve(
path.dirname(fileURLToPath(import.meta.url)),
'../..',
);
const result = spawnSync(
'npm',
['pkg', 'get', 'scripts.test:ci', '--workspaces', '--json'],
{ cwd: root, encoding: 'utf8', shell: process.platform === 'win32' },
);
expect(result.status, result.stderr).toBe(0);
const npmNames = Object.entries(JSON.parse(result.stdout))
.filter(([, script]) => typeof script === 'string')
.map(([name]) => name)
.sort();
const selectorNames = getTestCiWorkspacePackageJsonPaths(root)
.map(
(packageJsonPath) =>
JSON.parse(readFileSync(path.join(root, packageJsonPath), 'utf8'))
.name,
)
.sort();
expect(npmNames).toEqual(selectorNames);
expect(npmNames).toContain('@qwen-code/web-shell');
});
function writeFile(root, relativePath, content) {
const filePath = path.join(root, relativePath);
mkdirSync(path.dirname(filePath), { recursive: true });
writeFileSync(filePath, content);
}
});