Publish Claude Code Binaries / Stage and publish binaries (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-arm64 (push) Canceled after 0s
Publish Tutti App Runtime / Build darwin-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build linux-amd64 (push) Canceled after 0s
Publish Tutti App Runtime / Build windows-amd64 (push) Canceled after 0s
Sync Tutti Agent Skills / sync (push) Canceled after 0s
Windows Agent Adapters / Test Windows agent adapters (push) Canceled after 0s
Windows Daemon Adapters / Test Windows daemon adapters (push) Canceled after 0s
Publish Tutti App Runtime / Publish runtime catalog (push) Canceled after 0s
291 lines
8.3 KiB
JavaScript
291 lines
8.3 KiB
JavaScript
import { spawnSync } from "node:child_process";
|
|
import { existsSync, readFileSync } from "node:fs";
|
|
import { dirname, join } from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import {
|
|
readPositiveIntegerOption,
|
|
runValidationLanes
|
|
} from "./run-validation-lanes.mjs";
|
|
|
|
const scriptDirectory = dirname(fileURLToPath(import.meta.url));
|
|
const workspaceRoot = join(scriptDirectory, "..", "..");
|
|
|
|
if (isMainModule()) {
|
|
const toolsOnly = process.argv.includes("--tools-only");
|
|
const packageNames = parseWorkspaceTestPackageFilters(process.argv.slice(2));
|
|
const shard = parseWorkspaceTestShard(process.argv.slice(2));
|
|
const trackedFiles = gitLines([
|
|
"ls-files",
|
|
"--cached",
|
|
"--others",
|
|
"--exclude-standard"
|
|
]).filter((path) => existsSync(join(workspaceRoot, path)));
|
|
const packageJsonPaths = gitLines([
|
|
"ls-files",
|
|
"--cached",
|
|
"--others",
|
|
"--exclude-standard",
|
|
"apps/*/package.json",
|
|
"packages/*/*/package.json",
|
|
"services/tuttid/builtin-apps/*/package.json",
|
|
"tools/fixtures/*/package.json"
|
|
]).filter((path) => existsSync(join(workspaceRoot, path)));
|
|
const plan = buildWorkspaceTestPlan({
|
|
packageJsonEntries: packageJsonPaths.map((path) => ({
|
|
path,
|
|
value: JSON.parse(readFileSync(join(workspaceRoot, path), "utf8"))
|
|
})),
|
|
toolsOnly,
|
|
trackedFiles
|
|
});
|
|
const packageSelection = selectWorkspaceTestPackages(
|
|
plan.packages,
|
|
packageNames
|
|
);
|
|
plan.errors.push(...packageSelection.errors);
|
|
plan.packages = shardWorkspaceTestPackages(packageSelection.packages, shard);
|
|
|
|
if (plan.errors.length > 0) {
|
|
for (const error of plan.errors) {
|
|
console.error(error);
|
|
}
|
|
process.exit(1);
|
|
}
|
|
|
|
const pnpmCommand = resolvePnpmCommand();
|
|
const lanes = plan.packages.map((packageInfo) => ({
|
|
command: [...pnpmCommand, "--filter", packageInfo.name, "test"],
|
|
key: packageInfo.name,
|
|
label: packageInfo.name
|
|
}));
|
|
if (plan.toolTests.length > 0) {
|
|
lanes.push({
|
|
command: [process.execPath, "--test", ...plan.toolTests],
|
|
key: "tools",
|
|
label: "tools"
|
|
});
|
|
}
|
|
|
|
const result = await runValidationLanes({
|
|
lanes,
|
|
maxParallel: readPositiveIntegerOption(
|
|
"--max-parallel",
|
|
process.env.CI ? 1 : 4
|
|
),
|
|
summaryLabel: toolsOnly ? "tool tests" : "workspace tests",
|
|
tailLines: readPositiveIntegerOption("--tail-lines", 80),
|
|
tmpDirectoryName: "test-runs/typescript",
|
|
workspaceRoot
|
|
});
|
|
process.exit(result.exitCode);
|
|
}
|
|
|
|
export function buildWorkspaceTestPlan({
|
|
packageJsonEntries,
|
|
toolsOnly,
|
|
trackedFiles
|
|
}) {
|
|
const testFilePattern = /(?:^|\/)[^/]+\.(?:spec|test)\.[cm]?[jt]sx?$/u;
|
|
const toolTests = toolsOnly
|
|
? trackedFiles
|
|
.filter(
|
|
(file) =>
|
|
file.startsWith("tools/scripts/") && file.endsWith(".test.mjs")
|
|
)
|
|
.sort()
|
|
: [];
|
|
const packages = [];
|
|
const errors = [];
|
|
|
|
if (!toolsOnly) {
|
|
for (const entry of packageJsonEntries) {
|
|
const name = entry.value.name;
|
|
const testScript = entry.value.scripts?.test;
|
|
if (!name || !testScript) {
|
|
continue;
|
|
}
|
|
const root = dirname(entry.path).replaceAll("\\", "/");
|
|
const packageTestFiles = trackedFiles.filter(
|
|
(file) => file.startsWith(`${root}/`) && testFilePattern.test(file)
|
|
);
|
|
if (packageTestFiles.length === 0) {
|
|
errors.push(
|
|
`${name} declares a test script but has no package test files; add a *.test.*/*.spec.* file or remove the stale script`
|
|
);
|
|
continue;
|
|
}
|
|
packages.push({ name, root, testFileCount: packageTestFiles.length });
|
|
}
|
|
}
|
|
|
|
if (toolsOnly && toolTests.length === 0) {
|
|
errors.push("tools/scripts contains no *.test.mjs files");
|
|
}
|
|
|
|
packages.sort((left, right) => left.root.localeCompare(right.root));
|
|
return { errors, packages, toolTests };
|
|
}
|
|
|
|
export function parseWorkspaceTestPackageFilters(args) {
|
|
let packageNames = null;
|
|
for (let index = 0; index < args.length; index += 1) {
|
|
const arg = args[index];
|
|
if (arg === "--" || arg === "--tools-only") {
|
|
continue;
|
|
}
|
|
if (arg === "--max-parallel" || arg === "--tail-lines") {
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (arg === "--shard") {
|
|
index += 1;
|
|
continue;
|
|
}
|
|
if (arg !== "--packages-json" || packageNames !== null) {
|
|
throw new Error(`unknown workspace test option: ${arg}`);
|
|
}
|
|
try {
|
|
packageNames = JSON.parse(args[++index] ?? "");
|
|
} catch {
|
|
throw new Error("--packages-json must contain valid JSON");
|
|
}
|
|
}
|
|
if (packageNames === null) {
|
|
return null;
|
|
}
|
|
if (
|
|
!Array.isArray(packageNames) ||
|
|
packageNames.length === 0 ||
|
|
packageNames.some((name) => typeof name !== "string" || name.length === 0)
|
|
) {
|
|
throw new Error("--packages-json must contain a non-empty string array");
|
|
}
|
|
return [...new Set(packageNames)];
|
|
}
|
|
|
|
export function parseWorkspaceTestShard(args) {
|
|
let shard = null;
|
|
for (let index = 0; index < args.length; index += 1) {
|
|
const arg = args[index];
|
|
if (arg === "--shard") {
|
|
if (shard !== null) {
|
|
throw new Error("--shard may only be provided once");
|
|
}
|
|
const value = args[++index] ?? "";
|
|
const match = /^(\d+)\/(\d+)$/u.exec(value);
|
|
if (!match) {
|
|
throw new Error("--shard must use the positive index/total format");
|
|
}
|
|
const indexValue = Number.parseInt(match[1], 10);
|
|
const total = Number.parseInt(match[2], 10);
|
|
if (indexValue < 1 || total < 1 || indexValue > total) {
|
|
throw new Error("--shard index must be between 1 and its total");
|
|
}
|
|
shard = { index: indexValue, total };
|
|
continue;
|
|
}
|
|
if (arg === "--" || arg === "--tools-only" || arg === "--packages-json") {
|
|
if (arg === "--packages-json") {
|
|
index += 1;
|
|
}
|
|
continue;
|
|
}
|
|
if (arg === "--max-parallel" || arg === "--tail-lines") {
|
|
index += 1;
|
|
continue;
|
|
}
|
|
}
|
|
return shard;
|
|
}
|
|
|
|
export function selectWorkspaceTestPackages(packages, packageNames) {
|
|
if (packageNames === null) {
|
|
return { errors: [], packages };
|
|
}
|
|
|
|
const selectedNames = new Set(packageNames);
|
|
const packageNameSet = new Set(
|
|
packages.map((packageConfig) => packageConfig.name)
|
|
);
|
|
const unknownNames = packageNames.filter((name) => !packageNameSet.has(name));
|
|
return {
|
|
errors:
|
|
unknownNames.length === 0
|
|
? []
|
|
: [
|
|
`Unknown workspace test package filter(s): ${unknownNames.join(", ")}`
|
|
],
|
|
packages: packages.filter((packageConfig) =>
|
|
selectedNames.has(packageConfig.name)
|
|
)
|
|
};
|
|
}
|
|
|
|
export function shardWorkspaceTestPackages(packages, shard) {
|
|
if (shard === null) {
|
|
return packages;
|
|
}
|
|
|
|
const buckets = Array.from({ length: shard.total }, () => ({
|
|
packageNames: new Set(),
|
|
testFileCount: 0
|
|
}));
|
|
const packagesByWeight = packages
|
|
.map((packageConfig, index) => ({ index, packageConfig }))
|
|
.sort(
|
|
(left, right) =>
|
|
(right.packageConfig.testFileCount ?? 1) -
|
|
(left.packageConfig.testFileCount ?? 1) || left.index - right.index
|
|
);
|
|
|
|
for (const { packageConfig } of packagesByWeight) {
|
|
const bucket = buckets.reduce((lightest, candidate) =>
|
|
candidate.testFileCount < lightest.testFileCount ? candidate : lightest
|
|
);
|
|
bucket.packageNames.add(packageConfig.name);
|
|
bucket.testFileCount += packageConfig.testFileCount ?? 1;
|
|
}
|
|
|
|
const selectedNames = buckets[shard.index - 1].packageNames;
|
|
return packages.filter((packageConfig) =>
|
|
selectedNames.has(packageConfig.name)
|
|
);
|
|
}
|
|
|
|
function gitLines(args) {
|
|
const result = spawnSync("git", args, {
|
|
cwd: workspaceRoot,
|
|
encoding: "utf8"
|
|
});
|
|
if (result.status !== 0) {
|
|
throw new Error(result.stderr.trim() || `git ${args.join(" ")} failed`);
|
|
}
|
|
return result.stdout
|
|
.split("\n")
|
|
.map((line) => line.trim())
|
|
.filter(Boolean);
|
|
}
|
|
|
|
function resolvePnpmCommand() {
|
|
const fallback = [process.platform === "win32" ? "pnpm.cmd" : "pnpm"];
|
|
try {
|
|
const packageJson = JSON.parse(
|
|
readFileSync(join(workspaceRoot, "package.json"), "utf8")
|
|
);
|
|
const match = /^pnpm@(.+)$/u.exec(String(packageJson.packageManager ?? ""));
|
|
if (!match) {
|
|
return fallback;
|
|
}
|
|
return [
|
|
process.platform === "win32" ? "corepack.cmd" : "corepack",
|
|
`pnpm@${match[1]}`
|
|
];
|
|
} catch {
|
|
return fallback;
|
|
}
|
|
}
|
|
|
|
function isMainModule() {
|
|
return process.argv[1] && fileURLToPath(import.meta.url) === process.argv[1];
|
|
}
|