openclaw/test/scripts/ci-node-test-plan.test-support.ts
Peter Steinberger 5982006319
fix(slack): progress card links open the visible work session instead of the main session (#160088)
* fix(slack): link finished progress cards to the visible work session

Slack progress cards linked "Open in OpenClaw" to the conversation's main
session even when the turn spawned a visible work session, so operators on
groupScope "main" landed on /chat/<agent> instead of the child doing the work.

Carry the child's canonical URL from its producer: accepted visible spawn
receipts keep sessionUrl and label across the embedded, Codex app-server,
and CLI runners; the settled run reports them once through a new optional
GetReplyOptions.onVisibleWorkSessions before final delivery; Slack's native
task card and Block Kit card render "Open work session" for one child,
labeled links in acceptance order (max five) for several, and keep the
conversation link when none was spawned. No request-time session discovery.

To satisfy the line-cap ratchet, the visible-spawn TypeBox schema moves to
sessions-spawn-visible.schema.ts and the new cases live in sibling test
files with shared test-support fixtures.

* fix(slack): match only generated session-link action IDs

The link-only acknowledgement matched any action_id starting with
openclaw:session_link, so an unrelated button such as openclaw:session_linked
was acknowledged and dropped instead of reaching plugin or agent dispatch.
Accept only the bare ID or a numeric suffix, as the renderer emits.

* test(ci): accept measured command ceilings beside startup health

The PR's 771st database-worker file changes compact placement and exposes a
contradiction: capacity checks accept measured command groups' two-worker
ceiling, but the startup-health sibling check expected no ceiling.

Share the complete measured-generation lookup between both assertions and
recognize the existing command policy without changing planner, packing,
worker, or job-level policies. Move the unchanged tooling placement helpers
to the sibling support module, reducing the test's counted lines from 7536
to 7524.
2026-09-28 22:21:00 +00:00

177 lines
6.5 KiB
TypeScript

import { existsSync, globSync, readdirSync } from "node:fs";
import { isAbsolute, join, relative, resolve } from "node:path";
import { expect } from "vitest";
import type { CompactNodeTestShard } from "../../scripts/lib/ci-node-test-plan.mts";
import {
isReleaseOnlyRuntimeTestFile,
RELEASE_ONLY_RUNTIME_TEST_FILES,
} from "../../scripts/lib/ci-proof-test-inventory.mts";
import { parseCompactSplitTimingKey } from "../../scripts/lib/vitest-shard-metadata.mts";
import { listGitTrackedFiles, sortRepoPaths, toRepoPath } from "../../src/test-utils/repo-files.js";
import { createCliVitestConfig } from "../vitest/vitest.cli.config.ts";
type VitestTestConfig = {
dir?: string;
exclude?: string[];
include?: string[];
};
type VitestConfig = {
test?: VitestTestConfig;
};
export function isNumberedToolingGroup(group: { shard_name: string }) {
return /^core-tooling-\d+(?:-hosted-\d+)?$/u.test(group.shard_name);
}
export function nonToolingPlacement(plan: CompactNodeTestShard[]) {
return plan
.flatMap((job) => {
const groups = job.groups
.filter((group) => !isNumberedToolingGroup(group))
.map((group) => group.shard_name)
.toSorted();
return groups.length === 0
? []
: [
{
groups,
planConcurrency: job.planConcurrency,
pretestBuildMode: job.pretestBuildMode,
requiresDist: job.requiresDist,
runner: job.runner,
},
];
})
.toSorted((a, b) => a.groups.join("\0").localeCompare(b.groups.join("\0")));
}
export function listTestFiles(rootDir: string): string[] {
const gitFiles = listGitTrackedFiles({ pathspecs: rootDir });
expect(gitFiles).not.toBeNull();
if (gitFiles) {
return gitFiles.filter((line) => line.endsWith(".test.ts"));
}
if (!existsSync(rootDir)) {
return [];
}
const files: string[] = [];
const visit = (dir: string) => {
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const path = join(dir, entry.name);
if (entry.isDirectory()) {
visit(path);
} else if (entry.isFile() && entry.name.endsWith(".test.ts")) {
files.push(toRepoPath(path));
}
}
};
visit(rootDir);
return sortRepoPaths(files);
}
export function listMatchedTestFiles(config: VitestConfig): string[] {
const testConfig = config.test ?? {};
const cwd = testConfig.dir ? resolve(testConfig.dir) : process.cwd();
const exclude = (testConfig.exclude ?? []).map((pattern) =>
isAbsolute(pattern) ? toRepoPath(relative(cwd, pattern)) : toRepoPath(pattern),
);
// Unit-fast ownership contributes hundreds of literal file exclusions.
// Compare those exactly after discovery; keep directory/glob pruning native.
const exactFiles = new Set(exclude.filter((pattern) => /^[\w/.-]+\.test\.ts$/u.test(pattern)));
return globSync(testConfig.include ?? [], {
cwd,
exclude: exclude.filter((pattern) => !exactFiles.has(pattern)),
})
.filter((file) => !exactFiles.has(toRepoPath(relative(cwd, resolve(cwd, file)))))
.map((file) => toRepoPath(relative(process.cwd(), resolve(cwd, file))))
.toSorted((a, b) => a.localeCompare(b));
}
export function expectRuntimeReleaseInventory({
before,
after,
reducedOwners,
compactMode,
}: {
before: CompactNodeTestShard[];
after: CompactNodeTestShard[];
reducedOwners: string[];
compactMode: "push" | "pull-request";
}) {
const cliFiles = listMatchedTestFiles(createCliVitestConfig({}));
const groupFiles = (group: CompactNodeTestShard["groups"][number]) =>
group.includePatterns ?? (group.shard_name === "agentic-cli" ? cliFiles : []);
const files = (plan: CompactNodeTestShard[]) =>
plan.flatMap((shard) => shard.groups.flatMap(groupFiles));
const beforeFiles = files(before);
const afterFiles = files(after);
const timingLineage = (key: string) => {
const lineage = [key];
for (let split = parseCompactSplitTimingKey(key); split;) {
const parent = split.parentShardName;
lineage.push(parent);
split = parseCompactSplitTimingKey(parent);
}
return lineage;
};
const fullTimingParents = new Set(
before
.flatMap((shard) =>
shard.groups.flatMap((group) => timingLineage(group.timing_key ?? group.shard_name)),
)
// The comparison plan can already omit tooling from mixed owners.
.filter((parent) => !parent.startsWith("changed-")),
);
expect(beforeFiles.filter((file) => !afterFiles.includes(file)).toSorted()).toEqual(
RELEASE_ONLY_RUNTIME_TEST_FILES.filter(
(file) => compactMode === "pull-request" || !file.startsWith("test/scripts/"),
).toSorted(),
);
expect(afterFiles.filter((file) => !beforeFiles.includes(file))).toEqual([]);
expect(afterFiles).toContain("src/config/config-startup-corpus.test.ts");
for (const owner of reducedOwners) {
const owns = (group: { shard_name: string }) =>
group.shard_name === owner || group.shard_name.startsWith(`${owner}-hosted-`);
const reduced = after.flatMap((shard) => shard.groups).filter(owns);
const retainedFiles = before
.flatMap((shard) => shard.groups)
.filter(owns)
.flatMap(groupFiles)
.filter((file) => !isReleaseOnlyRuntimeTestFile(file));
expect(reduced.flatMap((group) => group.includePatterns ?? []).toSorted(), owner).toEqual(
retainedFiles.toSorted(),
);
if (retainedFiles.length === 0) {
expect(reduced, owner).toEqual([]);
}
if (owner === "agentic-cli") {
const full = before.flatMap((shard) => shard.groups).find(owns);
expect(full, "full CLI owner").toBeDefined();
for (const group of reduced) {
expect(group.shard_name).toMatch(/^agentic-cli(?:-hosted-\d+)?$/u);
expect(group.env).toEqual(full?.env);
expect(group.fallbackMaxWorkers).toBe(full?.fallbackMaxWorkers);
const job = after.find((shard) => shard.groups.includes(group));
expect(job?.planConcurrency).toBe(1);
}
}
for (const group of reduced) {
expect(group.timing_key, "reduced runtime timing identity").toBeTypeOf("string");
const timingKey = group.timing_key!;
const lineage = timingLineage(timingKey);
for (const parent of lineage) {
expect(fullTimingParents.has(parent), `${owner}: ${parent}`).toBe(false);
}
const timingParent = lineage.at(-1)!;
expect(
timingParent.replace(
/(?:#workers-4|#file-parallel-(?:2|8)|-parallel(?:-2)?(?:-native-serial)?(?:-stripes)?)$/u,
"",
),
).toBe(`changed-${owner}`);
}
}
}