mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 01:29:56 +00:00
768 lines
31 KiB
TypeScript
768 lines
31 KiB
TypeScript
// Protect complete, deterministic UI E2E partitions and timing fallback precedence.
|
|
import fs from "node:fs";
|
|
import { syncBuiltinESMExports } from "node:module";
|
|
import os from "node:os";
|
|
import path from "node:path";
|
|
import { fileURLToPath } from "node:url";
|
|
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
|
import type { TestSpecification } from "vitest/node";
|
|
import { spawnNodeEvalSync } from "../src/test-utils/node-process.ts";
|
|
import { DEFAULT_VITEST_TEST_TIMEOUT_MS } from "./vitest/vitest.timeouts.ts";
|
|
|
|
const repoRoot = path.resolve(import.meta.dirname, "..");
|
|
const timingPath = path.join(repoRoot, "config/ci-test-timings.json");
|
|
const tempDirs: string[] = [];
|
|
|
|
beforeEach(() => {
|
|
vi.resetModules();
|
|
vi.stubEnv("OPENCLAW_CI_TEST_TIMINGS", undefined);
|
|
});
|
|
|
|
afterEach(() => {
|
|
vi.restoreAllMocks();
|
|
syncBuiltinESMExports();
|
|
vi.unstubAllEnvs();
|
|
vi.resetModules();
|
|
for (const dir of tempDirs.splice(0)) {
|
|
fs.rmSync(dir, { force: true, recursive: true });
|
|
}
|
|
});
|
|
|
|
function useTimings(contents: string | null) {
|
|
const readFileSync = fs.readFileSync;
|
|
vi.spyOn(fs, "readFileSync").mockImplementation((file, options) => {
|
|
if ((file instanceof URL ? fileURLToPath(file) : file) === timingPath) {
|
|
if (contents === null) {
|
|
throw new Error("ENOENT");
|
|
}
|
|
return contents;
|
|
}
|
|
return readFileSync(file, options);
|
|
});
|
|
syncBuiltinESMExports();
|
|
}
|
|
|
|
function timingFile(fileSeconds: Record<string, number>, perFileOverheadSeconds = 0.6) {
|
|
return JSON.stringify({
|
|
version: 1,
|
|
updatedAt: "2026-08-27",
|
|
source: "fixture measurements",
|
|
uiE2e: { fileSeconds, perFileOverheadSeconds },
|
|
compactGroupSeconds: { blacksmith: {}, github: {} },
|
|
runtimePlacementTimings: { blacksmith: [], github: [] },
|
|
repoE2eFileSeconds: {},
|
|
});
|
|
}
|
|
|
|
function specifications(
|
|
paths: string[],
|
|
options: { name?: string; projectWorkers?: number | null; rootWorkers?: number } = {},
|
|
): TestSpecification[] {
|
|
const project = {
|
|
config: {
|
|
maxWorkers: options.projectWorkers === null ? undefined : (options.projectWorkers ?? 1),
|
|
},
|
|
name: options.name ?? "fixture",
|
|
vitest: { config: { maxWorkers: options.rootWorkers ?? 1 } },
|
|
};
|
|
return paths.map((moduleId) => ({ moduleId, project }) as TestSpecification);
|
|
}
|
|
|
|
function temporaryFiles(sizes: number[]): TestSpecification[] {
|
|
const tempDir = fs.mkdtempSync(path.join(os.tmpdir(), "openclaw-ui-e2e-shards-"));
|
|
tempDirs.push(tempDir);
|
|
return specifications(
|
|
sizes.map((bytes, index) => {
|
|
const moduleId = path.join(tempDir, `suite-${index}.e2e.test.ts`);
|
|
fs.writeFileSync(moduleId, "x".repeat(bytes));
|
|
return moduleId;
|
|
}),
|
|
);
|
|
}
|
|
|
|
async function partitionSpecifications(files: TestSpecification[], count = 11) {
|
|
const { UiE2eSequencer } = await import("./vitest/vitest.ui-e2e.sequencer.ts");
|
|
return Promise.all(
|
|
Array.from({ length: count }, async (_, index) => {
|
|
const sequencer = new UiE2eSequencer({
|
|
config: { shard: { count, index: index + 1 } },
|
|
} as never);
|
|
return sequencer.shard(files);
|
|
}),
|
|
);
|
|
}
|
|
|
|
async function partition(files: TestSpecification[], count = 11) {
|
|
return (await partitionSpecifications(files, count)).map((shard) =>
|
|
shard.map((file) => file.moduleId),
|
|
);
|
|
}
|
|
|
|
const standaloneFile = "ui/src/e2e/board-fixture.e2e.test.ts";
|
|
const bundledFile = "ui/src/e2e/mount-fallback.e2e.test.ts";
|
|
const serialBundledFile = "ui/src/e2e/chat-stream-runtime-budgets.e2e.test.ts";
|
|
const privateFile = "ui/src/e2e/approval-bootstrap.e2e.test.ts";
|
|
const qaLabFiles = [
|
|
"extensions/qa-lab/src/control-ui-automation-management.real-gateway.e2e.test.ts",
|
|
"extensions/qa-lab/src/control-ui-media-transcript.real-gateway.e2e.test.ts",
|
|
"extensions/qa-lab/src/session-host-command-state.real-gateway.e2e.test.ts",
|
|
"extensions/qa-lab/src/control-ui-openclaw-delegation.real-gateway.e2e.test.ts",
|
|
] as const;
|
|
const realGatewayFiles = [
|
|
"agent-file-lifecycle.real-gateway",
|
|
"chat-agent-avatar.real-gateway",
|
|
"chat-composer-websearch-kill-switch.real-gateway",
|
|
"chat-flow.catalog-bootstrap",
|
|
"chat-loading-performance.real-gateway",
|
|
"chat-project-media.real-gateway",
|
|
"chat-stop-finished-run.real-gateway",
|
|
"chat-thinking-metadata.real-gateway",
|
|
"chat-widget-sandbox.real-gateway",
|
|
"command-palette-catalog.real-gateway",
|
|
"control-ui-auth-transports",
|
|
"cron-duration-save.real-gateway",
|
|
"desktop-resize.real-gateway",
|
|
"device-alias-rename.real-gateway",
|
|
"device-platform-family.real-gateway",
|
|
"logs-lifecycle",
|
|
"mcp-app-conformance",
|
|
"model-api-keys.real-gateway",
|
|
"model-catalog-partial-refresh.real-gateway",
|
|
"model-picker-search.real-gateway",
|
|
"profile-page.real-gateway",
|
|
"quota-reset-status.real-gateway",
|
|
"session-progress-hovercard.real-gateway",
|
|
"usage-sessions-owner-attribution",
|
|
"worker-initial-setup.real-gateway",
|
|
]
|
|
.map((name) => `ui/src/e2e/${name}.e2e.test.ts`)
|
|
.concat(qaLabFiles);
|
|
const mcpFile = "ui/src/e2e/mcp-app-conformance.e2e.test.ts";
|
|
const builtGatewayFile = "ui/src/e2e/chat-widget-sandbox.real-gateway.e2e.test.ts";
|
|
|
|
type OwnershipProbe = {
|
|
files: Array<{
|
|
file: string;
|
|
project: string;
|
|
phase: number;
|
|
workers: number;
|
|
fileParallelism: boolean;
|
|
}>;
|
|
contexts: Array<{
|
|
name: string;
|
|
chromium?: { available: boolean };
|
|
url?: string | null;
|
|
bridge: boolean;
|
|
}>;
|
|
leases: Array<{ outDir: string; closed: boolean; removed: boolean }>;
|
|
shards: string[][];
|
|
steps: Array<{ builds: number; closes: number }>;
|
|
admissions: string[];
|
|
rootWorkers: number;
|
|
setupError?: string;
|
|
};
|
|
|
|
function probeOwnership(
|
|
options: {
|
|
prebuilt?: boolean;
|
|
filters?: string[];
|
|
cli?: string[];
|
|
project?: string[];
|
|
include?: string[];
|
|
skipRealGateway?: boolean;
|
|
available?: boolean;
|
|
initialize?: string[][];
|
|
failure?: "build" | "provide" | "admission";
|
|
} = {},
|
|
): OwnershipProbe {
|
|
const directory = fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), "oc-ui-ownership-")));
|
|
tempDirs.push(directory);
|
|
const eventsFile = path.join(directory, "leases.jsonl");
|
|
const admissionsFile = path.join(directory, "admissions.jsonl");
|
|
const resourceFile = path.join(directory, "resources.mjs");
|
|
fs.writeFileSync(
|
|
resourceFile,
|
|
`
|
|
import fs from "node:fs";
|
|
export const resolvePlaywrightChromiumExecutablePath = () => "/fixture/chromium";
|
|
export const canRunPlaywrightChromium = () => ${options.available !== false};
|
|
export default function admission(project) {
|
|
fs.appendFileSync(${JSON.stringify(admissionsFile)}, JSON.stringify(project.name) + "\\n");
|
|
if (${JSON.stringify(options.failure)} === "admission") throw new Error("fixture admission failed");
|
|
}
|
|
export async function startBundledControlUiE2eServer(outDir) {
|
|
fs.writeFileSync(outDir + "/bundle.html", "fixture");
|
|
const record = (closed) => fs.appendFileSync(${JSON.stringify(eventsFile)}, JSON.stringify({ outDir, closed }) + "\\n");
|
|
record(false);
|
|
if (${JSON.stringify(options.failure)} === "build") throw new Error("fixture build failed");
|
|
return { baseUrl: "http://127.0.0.1:12345/", close: async () => record(true) };
|
|
}
|
|
`,
|
|
);
|
|
const configFile = path.join(directory, "config.mjs");
|
|
fs.writeFileSync(
|
|
configFile,
|
|
`
|
|
import config from ${JSON.stringify(path.join(repoRoot, `test/vitest/vitest.ui-e2e${options.prebuilt ? "-prebuilt" : ""}.config.ts`))};
|
|
function instrument(config) {
|
|
return { ...config, resolve: { ...config.resolve, alias: [
|
|
{ find: /^.*\\/control-ui-e2e\\.ts$/, replacement: ${JSON.stringify(resourceFile)} },
|
|
{ find: /^.*\\/vitest\\.ui-e2e-prebuilt\\.global-setup\\.ts$/, replacement: ${JSON.stringify(resourceFile)} },
|
|
...(config.resolve?.alias ?? []),
|
|
] }, test: { ...config.test,
|
|
...(config.test?.projects ? { projects: config.test.projects.map(instrument) } : {}),
|
|
} };
|
|
}
|
|
export default instrument(config);
|
|
`,
|
|
);
|
|
const includeFile = path.join(directory, "include.json");
|
|
fs.writeFileSync(includeFile, JSON.stringify(options.include ?? []));
|
|
// Native discovery/setup owns selection; stub only the expensive resource and admission calls.
|
|
// The admission owner's separate tests exercise real freshness checks and teardown fingerprints.
|
|
// No browser tests run; selected specifications still decide which projects acquire resources.
|
|
const result = spawnNodeEvalSync(
|
|
`
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import { createVitest, parseCLI } from "vitest/node";
|
|
const filters = ${JSON.stringify(options.filters ?? [])};
|
|
const args = ["run", ...filters, ...${JSON.stringify(options.cli ?? [])}];
|
|
process.argv = [process.execPath, "vitest", ...args];
|
|
const { options: cliOptions } = parseCLI(["vitest", ...args]);
|
|
// Vitest's CLI makes --exclude additive before calling createVitest.
|
|
const { exclude: cliExclude, ...parsedOptions } = cliOptions;
|
|
const ctx = await createVitest("test", {
|
|
...parsedOptions, cliExclude,
|
|
root: ${JSON.stringify(repoRoot)}, config: ${JSON.stringify(configFile)},
|
|
configLoader: "runner", watch: false, project: ${JSON.stringify(options.project ?? [])},
|
|
});
|
|
const readEvents = () => fs.existsSync(${JSON.stringify(eventsFile)})
|
|
? fs.readFileSync(${JSON.stringify(eventsFile)}, "utf8").trim().split("\\n").map(JSON.parse) : [];
|
|
if (${JSON.stringify(options.failure)} === "provide") {
|
|
const root = ctx.getRootProject();
|
|
const provide = root.provide;
|
|
root.provide = (key, value) => {
|
|
if (key === "controlUiE2eServerBaseUrl") throw new Error("fixture provide failed");
|
|
return provide(key, value);
|
|
};
|
|
}
|
|
let report;
|
|
try {
|
|
const specs = [];
|
|
const steps = [];
|
|
let setupError;
|
|
for (const selection of ${JSON.stringify(options.initialize ?? [options.filters ?? []])}) {
|
|
const selected = await ctx.globTestSpecifications(selection);
|
|
specs.push(...selected.filter(spec => !specs.some(previous =>
|
|
previous.moduleId === spec.moduleId && previous.project === spec.project)));
|
|
try {
|
|
await ctx.initializeGlobalSetup(selected);
|
|
await ctx.initializeGlobalSetup(selected);
|
|
} catch (error) { setupError = error.message; }
|
|
const events = readEvents();
|
|
steps.push({ builds: events.filter(event => !event.closed).length,
|
|
closes: events.filter(event => event.closed).length });
|
|
}
|
|
const projects = [...new Set(specs.map(spec => spec.project))];
|
|
const relative = file => path.relative(${JSON.stringify(repoRoot)}, file).replaceAll("\\\\", "/");
|
|
const sequencer = new ctx.config.sequence.sequencer(ctx);
|
|
const shards = [];
|
|
for (let index = 1; index <= 11; index++) {
|
|
ctx.config.shard = { index, count: 11 };
|
|
shards.push((await sequencer.shard(specs)).map(spec => relative(spec.moduleId)));
|
|
}
|
|
report = {
|
|
// Filesystem discovery order is not a contract; retain every file/project pair.
|
|
files: specs.map(spec => ({ file: relative(spec.moduleId), project: spec.project.name,
|
|
phase: spec.project.config.sequence.groupOrder,
|
|
workers: spec.project.config.maxWorkers ?? ctx.config.maxWorkers,
|
|
fileParallelism: spec.project.config.fileParallelism,
|
|
}))
|
|
.toSorted((a, b) => a.file.localeCompare(b.file)),
|
|
contexts: projects.map(project => ({ name: project.name,
|
|
chromium: project.getProvidedContext().controlUiE2eChromium,
|
|
url: project.getProvidedContext().controlUiE2eServerBaseUrl,
|
|
bridge: project.config.setupFiles.some(file => file.endsWith("/vitest.ui-e2e.setup.ts")),
|
|
})), shards, steps, setupError, rootWorkers: ctx.config.maxWorkers,
|
|
admissions: fs.existsSync(${JSON.stringify(admissionsFile)})
|
|
? fs.readFileSync(${JSON.stringify(admissionsFile)}, "utf8").trim().split("\\n").map(JSON.parse) : [],
|
|
};
|
|
} finally { await ctx.close(); }
|
|
const events = readEvents();
|
|
report.leases = events.filter(event => !event.closed).map(event => ({
|
|
...event, closed: events.filter(other => other.outDir === event.outDir && other.closed).length === 1,
|
|
removed: !fs.existsSync(event.outDir),
|
|
}));
|
|
console.log("OWNERSHIP " + JSON.stringify(report));
|
|
`,
|
|
{
|
|
cwd: repoRoot,
|
|
timeout: DEFAULT_VITEST_TEST_TIMEOUT_MS,
|
|
env: {
|
|
...process.env,
|
|
OPENCLAW_VITEST_INCLUDE_FILE: options.include ? includeFile : "",
|
|
OPENCLAW_UI_E2E_SKIP_REAL_GATEWAY: options.skipRealGateway ? "1" : "",
|
|
},
|
|
},
|
|
);
|
|
expect(result.error, result.stderr).toBeUndefined();
|
|
expect(result.status, result.stderr || result.stdout).toBe(0);
|
|
const report = result.stdout.split("\n").find((line) => line.startsWith("OWNERSHIP "));
|
|
expect(report, result.stdout).toBeDefined();
|
|
return JSON.parse(report!.slice("OWNERSHIP ".length)) as OwnershipProbe;
|
|
}
|
|
|
|
describe("Control UI E2E resource ownership", () => {
|
|
it.each([
|
|
{ filters: [standaloneFile], files: [standaloneFile], leases: 0 },
|
|
...["control-ui-retained-assets", "service-worker-update"].map((name) => {
|
|
const file = `ui/src/e2e/${name}.e2e.test.ts`;
|
|
return { filters: [file], files: [file], leases: 0 };
|
|
}),
|
|
{ filters: [privateFile], files: [privateFile], leases: 0 },
|
|
{ filters: [bundledFile], files: [bundledFile], leases: 1 },
|
|
{ filters: [serialBundledFile], files: [serialBundledFile], leases: 1 },
|
|
{
|
|
filters: [bundledFile, serialBundledFile],
|
|
files: [bundledFile, serialBundledFile],
|
|
leases: 1,
|
|
},
|
|
{
|
|
filters: [standaloneFile, privateFile, bundledFile, serialBundledFile],
|
|
files: [standaloneFile, privateFile, bundledFile, serialBundledFile],
|
|
leases: 1,
|
|
},
|
|
{ filters: [standaloneFile, bundledFile], files: [standaloneFile, bundledFile], leases: 1 },
|
|
{ filters: ["ui/src/pages/tasks"], files: ["ui/src/pages/tasks/tasks.e2e.test.ts"], leases: 1 },
|
|
{
|
|
include: [standaloneFile, bundledFile],
|
|
files: [standaloneFile, bundledFile],
|
|
leases: 1,
|
|
},
|
|
{
|
|
include: [standaloneFile, bundledFile],
|
|
filters: [standaloneFile],
|
|
files: [standaloneFile],
|
|
leases: 0,
|
|
},
|
|
{
|
|
include: ["ui/src/e2e/**/*.test.ts"],
|
|
filters: [standaloneFile],
|
|
files: [standaloneFile],
|
|
leases: 0,
|
|
},
|
|
{
|
|
include: ["ui/src/e2e/**/*.test.ts"],
|
|
files: fs.globSync("ui/src/e2e/**/*.test.ts", { cwd: repoRoot }),
|
|
leases: 1,
|
|
},
|
|
{ include: [], files: [], leases: 0 },
|
|
{ filters: ["ui/src/e2e/does-not-exist.e2e.test.ts"], files: [], leases: 0 },
|
|
{ filters: [bundledFile], available: false, files: [bundledFile], leases: 0 },
|
|
{ prebuilt: true, filters: [builtGatewayFile], files: [builtGatewayFile], leases: 0 },
|
|
{ prebuilt: true, filters: [mcpFile], files: [mcpFile], leases: 0 },
|
|
{ prebuilt: true, filters: [bundledFile], files: [], leases: 0 },
|
|
{ prebuilt: true, skipRealGateway: true, files: [], leases: 0 },
|
|
{
|
|
prebuilt: true,
|
|
include: ["ui/src/e2e/*.real-gateway.e2e.test.ts"],
|
|
filters: [builtGatewayFile, bundledFile],
|
|
files: [builtGatewayFile],
|
|
leases: 0,
|
|
},
|
|
{
|
|
prebuilt: true,
|
|
include: [mcpFile, qaLabFiles[0]],
|
|
cli: ["--exclude", mcpFile],
|
|
files: [qaLabFiles[0]],
|
|
leases: 1,
|
|
},
|
|
])(
|
|
"scopes setup and leases to selection $filters / $include / $available",
|
|
({ files, leases, ...options }) => {
|
|
const result = probeOwnership(options);
|
|
expect(result.setupError).toBeUndefined();
|
|
const compareFiles = (left: string, right: string) => left.localeCompare(right);
|
|
expect(result.files.map((entry) => entry.file).toSorted(compareFiles)).toEqual(
|
|
files.toSorted(compareFiles),
|
|
);
|
|
expect(result.leases).toHaveLength(leases);
|
|
expect(result.leases.every((lease) => lease.closed && lease.removed)).toBe(true);
|
|
for (const context of result.contexts) {
|
|
expect(context.chromium?.available).toBe(options.available !== false);
|
|
const consumesBundle = ["ui-e2e-bundled", "ui-e2e-serial", "ui-e2e-real-gateway"].includes(
|
|
context.name,
|
|
);
|
|
// Root context is inherited by all projects; only consumers install the URL bridge.
|
|
expect(context.bridge).toBe(consumesBundle);
|
|
if (consumesBundle) {
|
|
expect(context.url).toBe(options.available === false ? null : "http://127.0.0.1:12345/");
|
|
}
|
|
}
|
|
expect(result.admissions.toSorted()).toEqual(
|
|
options.prebuilt ? result.contexts.map((context) => context.name).toSorted() : [],
|
|
);
|
|
},
|
|
);
|
|
|
|
it.each([
|
|
{ first: bundledFile, second: serialBundledFile, available: true },
|
|
{ first: serialBundledFile, second: bundledFile, available: true },
|
|
{ first: bundledFile, second: serialBundledFile, available: false },
|
|
{ first: serialBundledFile, second: bundledFile, available: false },
|
|
])(
|
|
"shares the bundle fact after standalone selection, $first then $second (Chromium: $available)",
|
|
({ first, second, available }) => {
|
|
const result = probeOwnership({
|
|
initialize: [[standaloneFile], [first], [second]],
|
|
available,
|
|
});
|
|
expect(result.setupError).toBeUndefined();
|
|
expect(result.steps).toEqual([
|
|
{ builds: 0, closes: 0 },
|
|
{ builds: available ? 1 : 0, closes: 0 },
|
|
{ builds: available ? 1 : 0, closes: 0 },
|
|
]);
|
|
expect(result.leases).toEqual(
|
|
available ? [{ outDir: expect.any(String), closed: true, removed: true }] : [],
|
|
);
|
|
expect(
|
|
result.contexts.filter((context) => context.bridge).map((context) => context.url),
|
|
).toEqual([
|
|
available ? "http://127.0.0.1:12345/" : null,
|
|
available ? "http://127.0.0.1:12345/" : null,
|
|
]);
|
|
},
|
|
);
|
|
|
|
it.each(["build", "provide"] as const)(
|
|
"cleans the private bundle after native %s failure",
|
|
(failure) => {
|
|
const result = probeOwnership({ filters: [bundledFile], failure });
|
|
expect(result.setupError).toBe(`fixture ${failure} failed`);
|
|
expect(result.leases).toEqual([
|
|
{ outDir: expect.any(String), closed: failure === "provide", removed: true },
|
|
]);
|
|
},
|
|
);
|
|
|
|
it("owns the complete inventory once and shards the project union without losing QA Lab or real-Gateway siblings", async () => {
|
|
const { createUiE2eVitestConfig, uiE2ePrivateServerTestFiles, uiE2eSerialTestFiles } =
|
|
await import("./vitest/vitest.ui-e2e.config.ts");
|
|
const config = createUiE2eVitestConfig();
|
|
const projects = config.test?.projects as
|
|
| Array<{ extends?: boolean; test?: { clearMocks?: boolean } }>
|
|
| undefined;
|
|
expect(config.test?.clearMocks).toBe(false);
|
|
expect(projects?.map((project) => project.extends)).toEqual([false, false, false, false]);
|
|
expect(projects?.map((project) => project.test?.clearMocks)).toEqual([
|
|
false,
|
|
false,
|
|
false,
|
|
false,
|
|
]);
|
|
const result = probeOwnership();
|
|
const inventory = fs
|
|
.globSync(["ui/src/**/*.e2e.test.ts", ...qaLabFiles], { cwd: repoRoot })
|
|
.toSorted();
|
|
expect(result.files.map((entry) => entry.file).toSorted()).toEqual(inventory);
|
|
expect(result.setupError).toBeUndefined();
|
|
expect(result.rootWorkers).toBeGreaterThan(0);
|
|
expect(result.rootWorkers).toBeLessThanOrEqual(2);
|
|
for (const entry of result.files) {
|
|
const serial = uiE2eSerialTestFiles.includes(entry.file);
|
|
expect(entry.project.startsWith("ui-e2e-serial")).toBe(serial);
|
|
expect(entry.phase).toBe(serial ? 1 : 0);
|
|
expect(entry.workers).toBe(serial ? 1 : result.rootWorkers);
|
|
if (uiE2ePrivateServerTestFiles.includes(entry.file)) {
|
|
expect(entry.project).toBe("ui-e2e-serial-standalone");
|
|
}
|
|
}
|
|
expect(result.leases).toEqual([{ outDir: expect.any(String), closed: true, removed: true }]);
|
|
expect(result.shards.flat().toSorted()).toEqual(inventory);
|
|
expect(new Set(result.shards.flat()).size).toBe(inventory.length);
|
|
for (const name of new Set(result.files.map((entry) => entry.project))) {
|
|
const selected = probeOwnership({ project: [name] });
|
|
expect(selected.files).toEqual(result.files.filter((entry) => entry.project === name));
|
|
expect(selected.leases).toHaveLength(name.endsWith("standalone") ? 0 : 1);
|
|
}
|
|
const realGateway = realGatewayFiles;
|
|
expect(
|
|
result.files
|
|
.filter((entry) => realGateway.includes(entry.file))
|
|
.map(({ file, project }) => ({ file, project })),
|
|
).toEqual(
|
|
realGateway.toSorted().map((file) => ({
|
|
file,
|
|
project: uiE2ePrivateServerTestFiles.includes(file)
|
|
? "ui-e2e-serial-standalone"
|
|
: "ui-e2e-serial",
|
|
})),
|
|
);
|
|
const skipped = probeOwnership({ skipRealGateway: true });
|
|
expect(skipped.files).toEqual(
|
|
result.files.filter((entry) => !realGateway.includes(entry.file)),
|
|
);
|
|
expect(skipped.shards.flat().toSorted()).toEqual(
|
|
inventory.filter((file) => !realGateway.includes(file)),
|
|
);
|
|
expect(result.admissions).toEqual([]);
|
|
expect(skipped.admissions).toEqual([]);
|
|
});
|
|
|
|
it.each([undefined, 1])(
|
|
"admits every prebuilt real-Gateway file once with the native worker cap %s",
|
|
async (workers) => {
|
|
const { uiE2ePrivateServerTestFiles } = await import("./vitest/vitest.ui-e2e.config.ts");
|
|
const result = probeOwnership({
|
|
prebuilt: true,
|
|
cli: workers === undefined ? [] : ["--maxWorkers", String(workers)],
|
|
});
|
|
expect(result.setupError).toBeUndefined();
|
|
expect(result.files.map((entry) => entry.file).toSorted()).toEqual(
|
|
realGatewayFiles.toSorted(),
|
|
);
|
|
expect(result.shards.flat().toSorted()).toEqual(realGatewayFiles.toSorted());
|
|
expect(new Set(result.shards.flat()).size).toBe(realGatewayFiles.length);
|
|
expect(result.rootWorkers).toBeGreaterThan(0);
|
|
expect(result.rootWorkers).toBeLessThanOrEqual(2);
|
|
if (workers !== undefined) {
|
|
expect(result.rootWorkers).toBe(workers);
|
|
}
|
|
expect(result.files.filter((entry) => entry.phase === 1)).toEqual([
|
|
{
|
|
file: "ui/src/e2e/desktop-resize.real-gateway.e2e.test.ts",
|
|
project: "ui-e2e-serial-standalone",
|
|
phase: 1,
|
|
workers: 1,
|
|
fileParallelism: false,
|
|
},
|
|
{
|
|
file: "ui/src/e2e/device-platform-family.real-gateway.e2e.test.ts",
|
|
project: "ui-e2e-serial-standalone",
|
|
phase: 1,
|
|
workers: 1,
|
|
fileParallelism: false,
|
|
},
|
|
{
|
|
file: mcpFile,
|
|
project: "ui-e2e-serial-standalone",
|
|
phase: 1,
|
|
workers: 1,
|
|
fileParallelism: false,
|
|
},
|
|
]);
|
|
const parallel = result.files.filter((entry) => entry.phase === 2);
|
|
expect(parallel).toHaveLength(26);
|
|
expect(parallel.every((entry) => entry.fileParallelism)).toBe(true);
|
|
expect(parallel.every((entry) => entry.workers === result.rootWorkers)).toBe(true);
|
|
for (const entry of parallel) {
|
|
expect(entry.project).toBe(
|
|
uiE2ePrivateServerTestFiles.includes(entry.file)
|
|
? "ui-e2e-real-gateway-standalone"
|
|
: "ui-e2e-real-gateway",
|
|
);
|
|
}
|
|
expect(result.admissions.toSorted()).toEqual(
|
|
result.contexts.map((entry) => entry.name).toSorted(),
|
|
);
|
|
expect(result.leases).toEqual([{ outDir: expect.any(String), closed: true, removed: true }]);
|
|
},
|
|
);
|
|
|
|
it("keeps prebuilt preview acquisition lazy across native project initialization", () => {
|
|
const result = probeOwnership({
|
|
prebuilt: true,
|
|
initialize: [[builtGatewayFile], [mcpFile], [qaLabFiles[0]], [qaLabFiles[1]]],
|
|
});
|
|
expect(result.setupError).toBeUndefined();
|
|
expect(result.steps).toEqual([
|
|
{ builds: 0, closes: 0 },
|
|
{ builds: 0, closes: 0 },
|
|
{ builds: 1, closes: 0 },
|
|
{ builds: 1, closes: 0 },
|
|
]);
|
|
expect(result.admissions).toHaveLength(3);
|
|
expect(new Set(result.admissions).size).toBe(3);
|
|
expect(result.leases).toEqual([{ outDir: expect.any(String), closed: true, removed: true }]);
|
|
});
|
|
|
|
it("propagates prebuilt admission failure before acquiring the preview", () => {
|
|
const result = probeOwnership({
|
|
prebuilt: true,
|
|
filters: [qaLabFiles[0]],
|
|
failure: "admission",
|
|
});
|
|
expect(result.setupError).toBe("fixture admission failed");
|
|
expect(result.admissions).toEqual(["ui-e2e-real-gateway"]);
|
|
expect(result.steps).toEqual([{ builds: 0, closes: 0 }]);
|
|
expect(result.leases).toEqual([]);
|
|
expect(result.contexts.every((context) => context.url === undefined)).toBe(true);
|
|
});
|
|
});
|
|
|
|
describe("Control UI E2E Vitest sharding", () => {
|
|
it("shares isolated cleanup policy with the duration weighted sequencer", async () => {
|
|
const [{ default: config }, { UiE2eSequencer }] = await Promise.all([
|
|
import("./vitest/vitest.ui-e2e.config.ts"),
|
|
import("./vitest/vitest.ui-e2e.sequencer.ts"),
|
|
]);
|
|
expect(config.test?.sequence?.sequencer).toBe(UiE2eSequencer);
|
|
expect(Number.isFinite(config.test?.hookTimeout)).toBe(true);
|
|
expect(config.test?.hookTimeout).toBeGreaterThan(0);
|
|
for (const project of config.test?.projects ?? []) {
|
|
expect(project).toMatchObject({
|
|
test: {
|
|
pool: "forks",
|
|
isolate: true,
|
|
runner: undefined,
|
|
hookTimeout: config.test?.hookTimeout,
|
|
},
|
|
});
|
|
}
|
|
});
|
|
|
|
it("balances unmeasured files by source bytes", async () => {
|
|
useTimings(null);
|
|
const shards = await partition(temporaryFiles([600, 500, 400, 300, 200, 100]), 3);
|
|
expect(
|
|
shards.map((shard) => shard.reduce((sum, file) => sum + fs.statSync(file).size, 0)),
|
|
).toEqual([700, 700, 700]);
|
|
});
|
|
|
|
it("uses repo-relative measurements before basename hints, then the byte proxy", async () => {
|
|
const measured = "ui/src/pages/chat/chat-flow.clipboard.e2e.test.ts";
|
|
const hinted = "ui/src/e2e/chat-flow.clipboard.e2e.test.ts";
|
|
useTimings(timingFile({ [measured]: 1 }));
|
|
const [large, small] = temporaryFiles([40 * 1024, 1024]);
|
|
const files = [
|
|
...specifications([measured, hinted].map((file) => path.join(repoRoot, file))),
|
|
large!,
|
|
small!,
|
|
];
|
|
const shards = await partition(files, 4);
|
|
expect(shards).toEqual([
|
|
[files[1]!.moduleId],
|
|
[large!.moduleId],
|
|
[files[0]!.moduleId],
|
|
[small!.moduleId],
|
|
]);
|
|
});
|
|
|
|
it("charges per-file overhead so many small suites do not look free", async () => {
|
|
useTimings(timingFile({}, 5));
|
|
const files = temporaryFiles([10_000, 3_000, 3_000, 3_000]);
|
|
const shards = await partition(files, 2);
|
|
expect(shards).toEqual([
|
|
[files[0]!.moduleId, files[3]!.moduleId],
|
|
[files[1]!.moduleId, files[2]!.moduleId],
|
|
]);
|
|
});
|
|
|
|
it("balances both project phases by their effective worker count", async () => {
|
|
const fixtureFiles = temporaryFiles([400, 200, 200, 100]);
|
|
const [bundledLarge, bundledSmall, serialLarge, serialSmall] = fixtureFiles.map(
|
|
(file) => file.moduleId,
|
|
);
|
|
const rawSeconds = new Map<string, number>([
|
|
[bundledLarge!, 10],
|
|
[bundledSmall!, 10],
|
|
[serialLarge!, 10],
|
|
[serialSmall!, 20],
|
|
]);
|
|
useTimings(
|
|
timingFile(
|
|
Object.fromEntries(
|
|
[...rawSeconds].map(([file, seconds]) => [path.relative(repoRoot, file), seconds]),
|
|
),
|
|
0,
|
|
),
|
|
);
|
|
const bundled = specifications([bundledLarge!, bundledSmall!], {
|
|
name: "ui-e2e-bundled",
|
|
projectWorkers: null,
|
|
rootWorkers: 2,
|
|
});
|
|
const serial = specifications([serialLarge!, serialSmall!], {
|
|
name: "ui-e2e-serial",
|
|
projectWorkers: 1,
|
|
rootWorkers: 2,
|
|
});
|
|
const shards = await partitionSpecifications([...bundled, ...serial], 2);
|
|
const workerCount = (file: TestSpecification) =>
|
|
(file.project.config.maxWorkers ?? file.project.vitest.config.maxWorkers)!;
|
|
|
|
expect(
|
|
shards.map((shard) =>
|
|
shard.reduce((sum, file) => sum + rawSeconds.get(file.moduleId)! / workerCount(file), 0),
|
|
),
|
|
).toEqual([20, 20]);
|
|
expect(
|
|
shards
|
|
.flat()
|
|
.map((file) => file.moduleId)
|
|
.toSorted(),
|
|
).toEqual(fixtureFiles.map((file) => file.moduleId).toSorted());
|
|
expect(new Set(shards.flat()).size).toBe(fixtureFiles.length);
|
|
expect(shards.flat().filter((file) => file.project.name === "ui-e2e-serial")).toHaveLength(2);
|
|
});
|
|
|
|
it("assigns every discovered file once with committed timings, ignoring stale keys", async () => {
|
|
const committed = fs.readFileSync(timingPath, "utf8");
|
|
const discovered = fs.globSync(["ui/src/**/*.e2e.test.ts", ...qaLabFiles], { cwd: repoRoot });
|
|
const { uiE2eSerialTestFiles } = await import("./vitest/vitest.ui-e2e.config.ts");
|
|
const serial = new Set(uiE2eSerialTestFiles);
|
|
const files = [
|
|
...specifications(
|
|
discovered.filter((file) => !serial.has(file)).map((file) => path.join(repoRoot, file)),
|
|
{ name: "ui-e2e-bundled", projectWorkers: null, rootWorkers: 2 },
|
|
),
|
|
...specifications(
|
|
discovered.filter((file) => serial.has(file)).map((file) => path.join(repoRoot, file)),
|
|
{ name: "ui-e2e-serial", projectWorkers: 1, rootWorkers: 2 },
|
|
),
|
|
];
|
|
expect(files.length).toBeGreaterThan(0);
|
|
useTimings(committed);
|
|
const original = await partition(files);
|
|
// Validate via the production loader before adding a stale but valid weight.
|
|
const { readUiE2eFileTimings } = await import("../scripts/lib/ci-test-timings.mts");
|
|
const timings = readUiE2eFileTimings();
|
|
expect(Object.keys(timings.fileSeconds).length).toBeGreaterThan(0);
|
|
vi.restoreAllMocks();
|
|
syncBuiltinESMExports();
|
|
vi.resetModules();
|
|
useTimings(
|
|
timingFile(
|
|
{ ...timings.fileSeconds, "ui/src/e2e/deleted.e2e.test.ts": 9999 },
|
|
timings.perFileOverheadSeconds,
|
|
),
|
|
);
|
|
expect(await partition(files.toReversed())).toEqual(original);
|
|
expect(original.flat().toSorted()).toEqual(files.map((file) => file.moduleId).toSorted());
|
|
expect(new Set(original.flat()).size).toBe(files.length);
|
|
});
|
|
|
|
it.each([
|
|
["truncated JSON", '{"version":1'],
|
|
["wrong version", timingFile({}).replace('"version":1', '"version":2')],
|
|
["negative seconds", timingFile({ "ui/src/e2e/chat-flow.clipboard.e2e.test.ts": -1 })],
|
|
])("preserves the no-file partition with %s", async (_name, contents) => {
|
|
const files = specifications(
|
|
fs
|
|
.globSync(["ui/src/**/*.e2e.test.ts", ...qaLabFiles], { cwd: repoRoot })
|
|
.map((file) => path.join(repoRoot, file)),
|
|
);
|
|
useTimings(null);
|
|
const baseline = await partition(files);
|
|
vi.restoreAllMocks();
|
|
syncBuiltinESMExports();
|
|
vi.resetModules();
|
|
useTimings(contents);
|
|
const actual = await partition(files);
|
|
expect(actual).toEqual(baseline);
|
|
expect(actual.flat().toSorted()).toEqual(files.map((file) => file.moduleId).toSorted());
|
|
expect(new Set(actual.flat()).size).toBe(files.length);
|
|
});
|
|
});
|