openclaw/test/scripts/tsdown-runtime-config.test.ts
RoboClaw d8999932cd
feat: export and install offline worker runtime artifacts (#188659)
* feat(workers): provision offline node runtime bundles

Preserve explicit npm profiles and their lock/reuse path while carrying qualified target dependencies in bundle archives.

Co-authored-by: galiniliev <5711535+galiniliev@users.noreply.github.com>

* feat: provision cloud worker runtimes without registry access

Worked on by:
- @galiniliev

Co-authored-by: galiniliev <5711535+galiniliev@users.noreply.github.com>
OpenClaw-Publication: 65d6807d-2851-4dba-9916-f4bc1496596b

* feat: expose offline worker runtime artifact preparation

Co-authored-by: galiniliev <5711535+galiniliev@users.noreply.github.com>

* feat: export and install offline worker runtime artifacts

Worked on by:
- @galiniliev

Co-authored-by: galiniliev <5711535+galiniliev@users.noreply.github.com>
OpenClaw-Publication: 3654bba2-696b-4a60-8fb9-a99de032625b

* fix(workers): retain repository package-manager prerequisites

Scope Node-only setup to bundle enrollment, fix narrowed bundle fixture metadata, and reuse the validated package manifest owner instead of unsafe assertions. Prove missing npm/Corepack repair and retained npm runtime compatibility.

Co-authored-by: galiniliev <5711535+galiniliev@users.noreply.github.com>

* Publish openclaw/p2c-offline-runtime-split

Worked on by:
- @galiniliev

Co-authored-by: galiniliev <5711535+galiniliev@users.noreply.github.com>
OpenClaw-Publication: 92b05551-9794-42c5-b0ea-0dbd82702c99

* fix(test): satisfy toolchain fixture lint

Keep fixture loops explicit without changing their behavior, and account for their exact test-line delta.

Co-authored-by: galiniliev <5711535+galiniliev@users.noreply.github.com>

* Publish openclaw/p2c-offline-runtime-split

Worked on by:
- @galiniliev

Co-authored-by: galiniliev <5711535+galiniliev@users.noreply.github.com>
OpenClaw-Publication: 4b152785-ec9c-414f-90c7-2368b3a060b3

* Publish openclaw/p2c-runtime-api-final

Worked on by:
- @galiniliev

Co-authored-by: galiniliev <5711535+galiniliev@users.noreply.github.com>
OpenClaw-Publication: 6d2083e0-fbd1-4410-b43b-515f5b21a52c

* test: keep declaration coverage at its authoritative boundary

Remove the duplicate config.entry assertion and shrink the API test allocation by its exact six-line delta. The canonical dts.entry test still covers the public runtime-artifacts declaration.

Co-authored-by: galiniliev <5711535+galiniliev@users.noreply.github.com>

* Publish openclaw/p2c-runtime-api-final

Worked on by:
- @galiniliev

Co-authored-by: galiniliev <5711535+galiniliev@users.noreply.github.com>
OpenClaw-Publication: 13b4786d-9391-4ac9-a9f8-779960f09c7f

* Publish openclaw/p2c-runtime-api-final

Worked on by:
- @galiniliev

Co-authored-by: galiniliev <5711535+galiniliev@users.noreply.github.com>
OpenClaw-Publication: d7740df3-4673-4768-bdba-c59f6d7bf07e

* fix: package npm dependencies named after Node builtins

Resolve declared package names and ancestor placements with package-style lookups. Preserve nested versions, source links and required dependency guards.

Co-authored-by: galiniliev <5711535+galiniliev@users.noreply.github.com>

* fix(codex): align bundled fs-safe with the host runtime

Use the already-admitted root fs-safe0.26.15 pin so selected Codex packages satisfy the canonical single-version contract. Regenerate only the Codex lockfile importer.

Co-authored-by: galiniliev <5711535+galiniliev@users.noreply.github.com>

* fix: retain installed dependencies for built bundled plugins

Validate source metadata for built plugin selections and use that installation's dependency graph. Published packages without source metadata keep their installed built root; mismatched and malformed generations remain rejected.

Co-authored-by: galiniliev <5711535+galiniliev@users.noreply.github.com>

* Publish openclaw/p2c-runtime-api-final

Worked on by:
- @galiniliev

Co-authored-by: galiniliev <5711535+galiniliev@users.noreply.github.com>
OpenClaw-Publication: c3c308fe-c901-4c1d-902a-3ccef24575a6

* test: account for formatted runtime artifact regressions

Allocate exactly four new LF-counted API test lines plus the separate35-line package-cycle allocation. Preserve inherited test debt and leave the shared baseline and comparator unchanged.

Co-authored-by: galiniliev <5711535+galiniliev@users.noreply.github.com>

* Publish openclaw/p2c-runtime-api-final

Worked on by:
- @galiniliev

Co-authored-by: galiniliev <5711535+galiniliev@users.noreply.github.com>
OpenClaw-Publication: ed6b8a9e-b71d-42d1-ad1d-5c0400e84abd

* test: qualify rollout catalog watcher delivery

Keep real rollout filesystem changes and exact final membership assertions, but reconcile after explicit observer delivery. Exercise cached leaves with coarse directory timestamps and use controlled healthy watchers for loss/cleanup proof.

Co-authored-by: galiniliev <5711535+galiniliev@users.noreply.github.com>

* Publish openclaw/p2c-runtime-api-final

Worked on by:
- @galiniliev

Co-authored-by: galiniliev <5711535+galiniliev@users.noreply.github.com>
OpenClaw-Publication: 1451be59-4dd0-4d32-bbb5-907edc3c8d33

* docs: clarify offline runtime enrollment prerequisites

Ignore target-specific node runtime dependency inputs. Distinguish bundle enrollment Node and tar prerequisites from explicit npm installs and repository npm/Corepack setup.

Co-authored-by: galiniliev <5711535+galiniliev@users.noreply.github.com>

* Publish openclaw/p2c-runtime-api-final

Worked on by:
- @galiniliev

Co-authored-by: galiniliev <5711535+galiniliev@users.noreply.github.com>
OpenClaw-Publication: 97f753f5-6efb-4e82-bf16-b34a75076a1d

* test(gateway): complete shared diagnostics logger

Reuse the canonical Workboard fixture repair from 5b08bece95. Exercise terminal hooks with diagnostics explicitly enabled, restore the prior flag, and allocate only the seven added regression lines.

Co-authored-by: galiniliev <5711535+galiniliev@users.noreply.github.com>

* Publish openclaw/p2c-runtime-api-final

Worked on by:
- @galiniliev

Co-authored-by: galiniliev <5711535+galiniliev@users.noreply.github.com>
OpenClaw-Publication: 885d6f42-0ec8-4cda-9cd3-a894a26e0861

* Publish openclaw/p2c-runtime-api-final

Worked on by:
- @galiniliev

Co-authored-by: galiniliev <5711535+galiniliev@users.noreply.github.com>
OpenClaw-Publication: d139f994-a008-4727-a871-cfb342706b2b

---------

Co-authored-by: galiniliev <5711535+galiniliev@users.noreply.github.com>
2026-10-31 13:29:08 +08:00

272 lines
11 KiB
TypeScript

// Covers bundling rules encoded in the root tsdown config.
import { readFileSync } from "node:fs";
import path from "node:path";
import { expectDefined } from "@openclaw/normalization-core/expect";
import type { TsdownPluginOption, UserConfig } from "tsdown";
import { afterEach, describe, expect, it, vi } from "vitest";
import { buildPluginSdkPackageExports } from "../../scripts/lib/plugin-sdk-entries.mts";
import { importFreshModule } from "../../src/plugin-sdk/test-helpers/import-fresh.js";
import { OPENCLAW_STATE_SCHEMA_SQL } from "../../src/state/openclaw-state-schema.js";
import tsdownConfig, {
createStateSchemaInlinePlugin,
STATE_SCHEMA_INLINE_PLUGIN_NAME,
} from "../../tsdown.config.ts";
// Keep config assertions aligned with tsdown's nested, async plugin slots.
async function resolvePluginNames(plugins: TsdownPluginOption): Promise<string[]> {
const resolved = await plugins;
if (!resolved) {
return [];
}
if (Array.isArray(resolved)) {
return (await Promise.all(resolved.map(resolvePluginNames))).flat();
}
if (!("name" in resolved)) {
throw new Error("expected a named plugin in build config assertions");
}
return [resolved.name];
}
function entrySources(config: UserConfig): Record<string, string | string[]> {
if (!config.entry || typeof config.entry !== "object" || Array.isArray(config.entry)) {
return {};
}
return config.entry;
}
function runtimeGraph(entry: string) {
const graphs = tsdownConfig.filter(
(config) =>
!(typeof config.dts === "object" && config.dts.emitDtsOnly) &&
Object.hasOwn(entrySources(config), entry),
);
expect(graphs).toHaveLength(1);
return expectDefined(graphs[0], `${entry} runtime graph`);
}
async function inputOptions(config: UserConfig) {
if (typeof config.inputOptions !== "function") {
throw new Error("expected runtime input options factory");
}
return config.inputOptions({}, "es", { cjsDts: false });
}
afterEach(() => vi.unstubAllEnvs());
describe("tsdown config", () => {
it("builds the public runtime-artifacts export", () => {
const manifest = JSON.parse(readFileSync("package.json", "utf8"));
expect(manifest.exports["./runtime-artifacts"]).toEqual({
types: "./dist/runtime-artifacts.d.ts",
default: "./dist/runtime-artifacts.js",
});
expect(entrySources(runtimeGraph("runtime-artifacts"))["runtime-artifacts"]).toBe(
"src/runtime-artifacts.ts",
);
});
it("keeps isolated and unselected plugin workers out of the host build graph", async () => {
vi.stubEnv("OPENCLAW_BUILD_PRIVATE_QA", "0");
const { default: selectedConfigs } = await importFreshModule<
typeof import("../../tsdown.config.ts")
>(import.meta.url, "../../tsdown.config.ts?isolated-worker-build");
const host = expectDefined(
selectedConfigs.find((config) =>
Object.hasOwn(entrySources(config), "plugins/runtime/index"),
),
"host build graph",
);
const sources = Object.values(entrySources(host))
.flat()
.map((source) =>
path.relative(process.cwd(), path.resolve(source)).replaceAll(path.sep, "/"),
);
for (const plugin of [
"codex",
"discord",
"imessage",
"memory-lancedb",
"onnx",
"team-reports",
"qa-lab",
]) {
expect(sources.filter((source) => source.startsWith(`extensions/${plugin}/`))).toEqual([]);
}
});
it.each(["0", "1"])(
"emits QA transport facades only for private QA builds (%s)",
async (mode) => {
vi.stubEnv("OPENCLAW_BUILD_PRIVATE_QA", mode);
const { default: selectedConfigs } = await importFreshModule<
typeof import("../../tsdown.config.ts")
>(import.meta.url, `../../tsdown.config.ts?private-qa=${mode}`);
const runtimeEntries = selectedConfigs
.filter((config) => !(typeof config.dts === "object" && config.dts.emitDtsOnly))
.flatMap((config) => Object.entries(entrySources(config)));
const packageExports = buildPluginSdkPackageExports();
for (const subpath of ["qa-channel", "qa-channel-protocol", "qa-lab", "qa-runtime"]) {
const matches = runtimeEntries.filter(([name]) => name === `plugin-sdk/${subpath}`);
expect(matches).toEqual(
mode === "1" ? [[`plugin-sdk/${subpath}`, `src/plugin-sdk/${subpath}.ts`]] : [],
);
expect(Object.hasOwn(packageExports, `./plugin-sdk/${subpath}`)).toBe(false);
}
},
);
it("inlines canonical schema bytes and invalidates the transform cache", () => {
const rootDir = process.cwd();
const modulePath = path.resolve("src/state/openclaw-state-schema.ts");
const schemaPath = path.resolve("src/state/openclaw-state-schema.sql");
const watchedPaths: string[] = [];
const plugin = createStateSchemaInlinePlugin(rootDir);
let cacheKeyGenerator: ((context: { id: string }) => string | undefined) | undefined;
plugin.configureVitest({
defineCacheKeyGenerator: (generator) => {
cacheKeyGenerator = generator;
},
});
const result = plugin.load.call(
{ addWatchFile: (filePath: string) => watchedPaths.push(filePath) },
modulePath,
);
const canonicalSql = readFileSync(schemaPath, "utf8");
const match = result?.code.match(/^export const OPENCLAW_STATE_SCHEMA_SQL = (.*);\n$/su);
expect(match?.[1]).toBeDefined();
expect(JSON.parse(match?.[1] ?? "null")).toBe(canonicalSql);
expect(OPENCLAW_STATE_SCHEMA_SQL).toBe(canonicalSql);
expect(watchedPaths).toEqual([schemaPath]);
expect(cacheKeyGenerator?.({ id: modulePath })).toBe(canonicalSql);
expect(cacheKeyGenerator?.({ id: path.resolve(rootDir, "src/index.ts") })).toBeUndefined();
});
it("installs schema inlining only on executable runtime graphs", async () => {
for (const entry of [
"plugins/runtime/index",
"worker/worker",
"managed-handoff-runtime",
"package-update-activation-recovery",
"native-hook-relay/entry",
"infra/sqlite-readonly-location.worker",
// Regression: the transcript reader moved out of the shared graph (#182259).
"config/sessions/session-transcript.worker",
]) {
const inlinePlugins = (await resolvePluginNames(runtimeGraph(entry).plugins)).filter(
(name) => name === STATE_SCHEMA_INLINE_PLUGIN_NAME,
);
expect(inlinePlugins, entry).toHaveLength(1);
}
for (const config of tsdownConfig.filter(
(candidate) => typeof candidate.dts === "object" && candidate.dts.emitDtsOnly,
)) {
expect(await resolvePluginNames(config.plugins)).not.toContain(
STATE_SCHEMA_INLINE_PLUGIN_NAME,
);
}
});
it("isolates relay startup from shared runtime chunks while retaining lazy fallback", async () => {
const relay = runtimeGraph("native-hook-relay/entry");
expect(entrySources(relay)).toEqual({
"native-hook-relay/entry": "src/cli/native-hook-relay-entry.ts",
});
expect(relay).not.toBe(runtimeGraph("plugins/runtime/index"));
expect(relay.dts).toBe(false);
expect(relay.outputOptions).not.toHaveProperty("codeSplitting", false);
expect(relay.outputOptions).toHaveProperty(
"chunkFileNames",
"native-hook-relay/[name]-[hash].mjs",
);
// Only the shared graph may publish the global plugin ownership manifest.
expect(await resolvePluginNames(relay.plugins)).not.toContain(
"openclaw:runtime-dependency-ownership",
);
});
it.each([
"plugins/plugin-generation-preparation.worker",
"infra/sqlite-readonly-location.worker",
"infra/sqlite-source-revision.worker",
])("emits %s once without sealing its package loaders", (entry) => {
const child = runtimeGraph(entry);
expect(entrySources(child)).toEqual({ [entry]: path.resolve(`src/${entry}.ts`) });
expect(child.outputOptions).toMatchObject({
codeSplitting:
entry === "infra/sqlite-readonly-location.worker" ||
entry === "plugins/plugin-generation-preparation.worker",
});
if (entry === "plugins/plugin-generation-preparation.worker") {
expect(child.outputOptions).toHaveProperty(
"chunkFileNames",
"plugins/plugin-preparation-[name]-[hash].mjs",
);
}
expect(child.outExtensions?.({ options: {}, format: "es" })?.js).toBe(".js");
expect(child.define?.SEALED_RUNTIME_BUILD).toBeUndefined();
});
it.each([
[
"config/sessions/session-transcript-reconcile",
"src/config/sessions/session-transcript-reconcile.ts",
],
[
"telegram-ingress-worker.runtime",
"extensions/telegram/src/telegram-ingress-worker.runtime.ts",
],
])("keeps %s behind its stable root dist entry", (entry, source) => {
const actual = entrySources(runtimeGraph("plugins/runtime/index"))[entry];
if (typeof actual !== "string") {
throw new Error(`expected one source for ${entry}`);
}
expect(path.resolve(actual)).toBe(path.resolve(source));
});
it("bundles SDK-owned helpers while retaining native package ownership", async () => {
for (const [graph, bundleFsSafe] of [
[runtimeGraph("plugins/runtime/index"), false],
[runtimeGraph("infra/sqlite-readonly-location.worker"), true],
] as const) {
const alwaysBundle = graph.deps?.alwaysBundle;
const external = (await inputOptions(graph))?.external;
if (typeof alwaysBundle !== "function" || typeof external !== "function") {
throw new Error("expected runtime graph dependency predicates");
}
expect(alwaysBundle("@openclaw/fs-safe/path", undefined)).toBe(bundleFsSafe);
expect(external("@openclaw/fs-safe/path", undefined, false)).toBe(!bundleFsSafe);
for (const specifier of ["@openclaw/proc-safe/windows-job"]) {
expect(alwaysBundle(specifier.split("/").slice(0, 2).join("/"), undefined)).toBe(false);
expect(alwaysBundle(specifier, undefined)).toBe(false);
expect(external(specifier, undefined, false)).toBe(true);
}
expect(alwaysBundle("openclaw/plugin-sdk/ssrf-runtime-internal", undefined)).toBe(true);
expect(alwaysBundle("openclaw/plugin-sdk/ssrf-runtime", undefined)).toBe(false);
expect(alwaysBundle("zod", undefined)).toBe(true);
expect(alwaysBundle("zod/v4/core", undefined)).toBe(true);
for (const id of ["typebox", "typebox/schema"]) {
expect(alwaysBundle(id, undefined)).toBe(false);
expect(external(id, undefined, false)).toBe(true);
}
expect(alwaysBundle("not-a-runtime-dependency", undefined)).toBe(false);
}
});
it("suppresses unresolved imports from extension source", async () => {
const configured = (await inputOptions(runtimeGraph("plugins/runtime/index")))?.onLog;
const handled: unknown[] = [];
configured?.(
"warn",
{
code: "UNRESOLVED_IMPORT",
message: "Could not resolve '@azure/identity' in extensions/msteams/src/sdk.ts",
},
(_level, log) => handled.push(log),
);
expect(handled).toStrictEqual([]);
});
});