mirror of
https://github.com/openclaw/openclaw.git
synced 2026-10-03 01:29:56 +00:00
test(scripts,tts,ui): remove low-value tests (batch d153) (#163563)
* test(scripts): deslop s1024 tests * test(upgrade): deslop s1039 tests * test(scripts): deslop s1036 tests * test(scripts): deslop s1029 tests * test(release): deslop s1034 tests * test(tooling): deslop s1033 tests * test(tooling): deslop s1041 tests * test(tts): deslop s1000 tests * test(ui): deslop s1053 tests * test(ui): deslop s1056 tests
This commit is contained in:
parent
64e97dac1f
commit
b1c09f1088
41 changed files with 1072 additions and 2213 deletions
|
|
@ -63,7 +63,7 @@ function npmApprovals(approvals) {
|
|||
);
|
||||
}
|
||||
|
||||
export function releaseApprovalArtifactName({ parentRunId, parentRunAttempt }) {
|
||||
function releaseApprovalArtifactName({ parentRunId, parentRunAttempt }) {
|
||||
pattern(parentRunId, ID, "Parent run id");
|
||||
pattern(parentRunAttempt, ID, "Parent run attempt");
|
||||
return `openclaw-release-approval-v1-${parentRunId}-${parentRunAttempt}`;
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
import { DatabaseSync } from "node:sqlite";
|
||||
import { setImmediate as nextTurn } from "node:timers/promises";
|
||||
import { afterAll, afterEach, describe, expect, it } from "vitest";
|
||||
import type { OpenClawConfig } from "../config/types.js";
|
||||
import { LegacyPluginSdkResourceHost } from "../plugins/legacy-sdk-resource-host.js";
|
||||
|
|
@ -109,31 +110,37 @@ module.exports = { id: ${JSON.stringify(id)}, register(api) {
|
|||
runtime,
|
||||
state,
|
||||
prepare: () => runtime.tts.prepareTtsRequest({ cfg, text }),
|
||||
withEnvironment: (run: () => Promise<void>) =>
|
||||
withEnvAsync(
|
||||
{
|
||||
OPENCLAW_HOME: dir,
|
||||
OPENCLAW_STATE_DIR: dir,
|
||||
OPENCLAW_CONFIG_PATH: path.join(dir, "config.json"),
|
||||
OPENCLAW_TTS_PREFS: path.join(dir, "prefs.json"),
|
||||
},
|
||||
() => host.run(run),
|
||||
),
|
||||
async cleanup() {
|
||||
tailResume.resolve();
|
||||
await Promise.allSettled(tails);
|
||||
await host.close();
|
||||
clearPluginMetadataLifecycleCaches();
|
||||
async runTest(run: () => Promise<void>) {
|
||||
try {
|
||||
const cleanup = await waitForPluginCacheRetirement();
|
||||
expect(cleanup.failures).toEqual([]);
|
||||
await withEnvAsync(
|
||||
{
|
||||
OPENCLAW_HOME: dir,
|
||||
OPENCLAW_STATE_DIR: dir,
|
||||
OPENCLAW_CONFIG_PATH: path.join(dir, "config.json"),
|
||||
OPENCLAW_TTS_PREFS: path.join(dir, "prefs.json"),
|
||||
},
|
||||
() =>
|
||||
host.run(async () => {
|
||||
useNoBundledPlugins();
|
||||
await run();
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
for (const { database } of connections) {
|
||||
if (database.isOpen) {
|
||||
database.close();
|
||||
tailResume.resolve();
|
||||
await Promise.allSettled(tails);
|
||||
await host.close();
|
||||
clearPluginMetadataLifecycleCaches();
|
||||
try {
|
||||
const cleanup = await waitForPluginCacheRetirement();
|
||||
expect(cleanup.failures).toEqual([]);
|
||||
} finally {
|
||||
for (const { database } of connections) {
|
||||
if (database.isOpen) {
|
||||
database.close();
|
||||
}
|
||||
}
|
||||
Reflect.deleteProperty(globalThis, key);
|
||||
}
|
||||
Reflect.deleteProperty(globalThis, key);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
@ -148,156 +155,137 @@ afterAll(cleanupPluginLoaderFixturesForTest);
|
|||
describe("async speech preparation resources", () => {
|
||||
it("reuses registrations and preserves opaque overrides for synthesis after inspection retirement", async () => {
|
||||
const fixture = createPreparationFixture();
|
||||
try {
|
||||
await fixture.withEnvironment(async () => {
|
||||
useNoBundledPlugins();
|
||||
const inspection = await acquirePluginRegistryForInspection({ config: fixture.cfg });
|
||||
try {
|
||||
const prepared = await withPluginRuntimeRegistryScope(inspection.registry, async () => {
|
||||
const first = await fixture.prepare();
|
||||
for (let index = 0; index < 3; index++) {
|
||||
const next = await fixture.prepare();
|
||||
expect(
|
||||
next.directives.overrides.providerOverrides?.[fixture.id]?.voiceSettings ===
|
||||
first.directives.overrides.providerOverrides?.[fixture.id]?.voiceSettings,
|
||||
).toBe(true);
|
||||
}
|
||||
const synthesis = await fixture.runtime.tts.textToSpeechTelephony({
|
||||
cfg: first.cfg,
|
||||
text: first.directives.cleanedText,
|
||||
overrides: first.directives.overrides,
|
||||
});
|
||||
expect(synthesis.success).toBe(true);
|
||||
// Ordinary dispatch must restore the handle inside its exact owning plugin.
|
||||
await fixture.runTest(async () => {
|
||||
const inspection = await acquirePluginRegistryForInspection({ config: fixture.cfg });
|
||||
try {
|
||||
const prepared = await withPluginRuntimeRegistryScope(inspection.registry, async () => {
|
||||
const first = await fixture.prepare();
|
||||
for (let index = 0; index < 3; index++) {
|
||||
const next = await fixture.prepare();
|
||||
expect(
|
||||
fixture.state.receivedOverrides[0] === fixture.state.connections[0]?.opaque,
|
||||
next.directives.overrides.providerOverrides?.[fixture.id]?.voiceSettings ===
|
||||
first.directives.overrides.providerOverrides?.[fixture.id]?.voiceSettings,
|
||||
).toBe(true);
|
||||
return first;
|
||||
}
|
||||
const synthesis = await fixture.runtime.tts.textToSpeechTelephony({
|
||||
cfg: first.cfg,
|
||||
text: first.directives.cleanedText,
|
||||
overrides: first.directives.overrides,
|
||||
});
|
||||
expect(fixture.state.connections.length).toBe(1);
|
||||
await inspection.release();
|
||||
expect(fixture.state.connections[0]?.database.isOpen).toBe(true);
|
||||
expect(
|
||||
prepared.directives.overrides.providerOverrides?.[fixture.id]?.voiceSettings,
|
||||
).toHaveProperty("voiceId", "voice-42");
|
||||
const result = await fixture.runtime.tts.textToSpeechTelephony({
|
||||
cfg: prepared.cfg,
|
||||
text: prepared.directives.cleanedText,
|
||||
overrides: prepared.directives.overrides,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
expect(fixture.state.consumedVoices).toEqual(["voice-42", "voice-42"]);
|
||||
await fixture.host.close();
|
||||
expect(fixture.state.connections.every((entry) => !entry.database.isOpen)).toBe(true);
|
||||
expect(fixture.state.connections.every((entry) => entry.disposals === 1)).toBe(true);
|
||||
} finally {
|
||||
await inspection.release();
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
expect(synthesis.success).toBe(true);
|
||||
// Ordinary dispatch must restore the handle inside its exact owning plugin.
|
||||
expect(fixture.state.receivedOverrides[0] === fixture.state.connections[0]?.opaque).toBe(
|
||||
true,
|
||||
);
|
||||
return first;
|
||||
});
|
||||
expect(fixture.state.connections.length).toBe(1);
|
||||
await inspection.release();
|
||||
expect(fixture.state.connections[0]?.database.isOpen).toBe(true);
|
||||
expect(
|
||||
prepared.directives.overrides.providerOverrides?.[fixture.id]?.voiceSettings,
|
||||
).toHaveProperty("voiceId", "voice-42");
|
||||
const result = await fixture.runtime.tts.textToSpeechTelephony({
|
||||
cfg: prepared.cfg,
|
||||
text: prepared.directives.cleanedText,
|
||||
overrides: prepared.directives.overrides,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
expect(fixture.state.consumedVoices).toEqual(["voice-42", "voice-42"]);
|
||||
await fixture.host.close();
|
||||
expect(fixture.state.connections.every((entry) => !entry.database.isOpen)).toBe(true);
|
||||
expect(fixture.state.connections.every((entry) => entry.disposals === 1)).toBe(true);
|
||||
} finally {
|
||||
await inspection.release();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("retains the selected source before provider metadata can retire its inspection", async () => {
|
||||
const fixture = createPreparationFixture();
|
||||
try {
|
||||
await fixture.withEnvironment(async () => {
|
||||
useNoBundledPlugins();
|
||||
const inspection = await acquirePluginRegistryForInspection({ config: fixture.cfg });
|
||||
let retirement: Promise<void> | undefined;
|
||||
fixture.state.callbacks.onMetadata = () => {
|
||||
retirement ??= inspection.release();
|
||||
};
|
||||
try {
|
||||
const prepared = await withPluginRuntimeRegistryScope(
|
||||
inspection.registry,
|
||||
fixture.prepare,
|
||||
);
|
||||
await retirement;
|
||||
expect(prepared.directives.hasDirective).toBe(true);
|
||||
expect(fixture.state.connections[0]?.opaque.voiceId).toBe("voice-42");
|
||||
await fixture.host.close();
|
||||
expect(fixture.state.connections[0]?.disposals).toBe(1);
|
||||
} finally {
|
||||
await inspection.release();
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
await fixture.runTest(async () => {
|
||||
const inspection = await acquirePluginRegistryForInspection({ config: fixture.cfg });
|
||||
let retirement: Promise<void> | undefined;
|
||||
fixture.state.callbacks.onMetadata = () => {
|
||||
retirement ??= inspection.release();
|
||||
};
|
||||
try {
|
||||
const prepared = await withPluginRuntimeRegistryScope(inspection.registry, fixture.prepare);
|
||||
await retirement;
|
||||
expect(prepared.directives.hasDirective).toBe(true);
|
||||
expect(fixture.state.connections[0]?.opaque.voiceId).toBe("voice-42");
|
||||
await fixture.host.close();
|
||||
expect(fixture.state.connections[0]?.disposals).toBe(1);
|
||||
} finally {
|
||||
await inspection.release();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("owns failed projection cleanup until asynchronous disposal finishes", async () => {
|
||||
const fixture = createPreparationFixture();
|
||||
const disposeResume = createDeferredCore();
|
||||
try {
|
||||
await fixture.withEnvironment(async () => {
|
||||
useNoBundledPlugins();
|
||||
const inspection = await acquirePluginRegistryForInspection({ config: fixture.cfg });
|
||||
let retirement: Promise<void> | undefined;
|
||||
fixture.state.callbacks.onDispose = () => disposeResume.promise;
|
||||
fixture.state.callbacks.onProjection = () => {
|
||||
retirement ??= inspection.release();
|
||||
throw new Error("speech directive projection failed");
|
||||
};
|
||||
try {
|
||||
await expect(
|
||||
withPluginRuntimeRegistryScope(inspection.registry, fixture.prepare),
|
||||
).rejects.toThrow("speech directive projection failed");
|
||||
let closed = false;
|
||||
const closing = fixture.host.close().then(() => {
|
||||
closed = true;
|
||||
});
|
||||
await new Promise<void>((resolve) => {
|
||||
setImmediate(resolve);
|
||||
});
|
||||
expect(closed).toBe(false);
|
||||
disposeResume.resolve();
|
||||
await closing;
|
||||
expect(fixture.state.connections[0]?.disposals).toBe(1);
|
||||
} finally {
|
||||
disposeResume.resolve();
|
||||
await retirement;
|
||||
await inspection.release();
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
disposeResume.resolve();
|
||||
await fixture.cleanup();
|
||||
}
|
||||
await fixture.runTest(async () => {
|
||||
const inspection = await acquirePluginRegistryForInspection({ config: fixture.cfg });
|
||||
const disposeResume = createDeferredCore();
|
||||
let retirement: Promise<void> | undefined;
|
||||
fixture.state.callbacks.onDispose = () => disposeResume.promise;
|
||||
fixture.state.callbacks.onProjection = () => {
|
||||
retirement ??= inspection.release();
|
||||
throw new Error("speech directive projection failed");
|
||||
};
|
||||
try {
|
||||
await expect(
|
||||
withPluginRuntimeRegistryScope(inspection.registry, fixture.prepare),
|
||||
).rejects.toThrow("speech directive projection failed");
|
||||
let closed = false;
|
||||
const closing = fixture.host.close().then(() => {
|
||||
closed = true;
|
||||
});
|
||||
await nextTurn();
|
||||
expect(closed).toBe(false);
|
||||
disposeResume.resolve();
|
||||
await closing;
|
||||
expect(fixture.state.connections[0]?.disposals).toBe(1);
|
||||
} finally {
|
||||
disposeResume.resolve();
|
||||
await retirement;
|
||||
await inspection.release();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps failed projection resources alive through its tracked directive tail", async () => {
|
||||
const fixture = createPreparationFixture();
|
||||
try {
|
||||
await fixture.withEnvironment(async () => {
|
||||
useNoBundledPlugins();
|
||||
it.each([false, true])(
|
||||
"joins directive tails before closing resources (projection fails: %s)",
|
||||
async (fails) => {
|
||||
const fixture = createPreparationFixture();
|
||||
await fixture.runTest(async () => {
|
||||
const inspection = await acquirePluginRegistryForInspection({ config: fixture.cfg });
|
||||
let retirement: Promise<void> | undefined;
|
||||
fixture.state.callbacks.trackTail = true;
|
||||
fixture.state.callbacks.onProjection = () => {
|
||||
retirement ??= inspection.release();
|
||||
throw new Error("speech directive projection failed");
|
||||
};
|
||||
if (fails) {
|
||||
fixture.state.callbacks.onProjection = () => {
|
||||
retirement ??= inspection.release();
|
||||
throw new Error("speech directive projection failed");
|
||||
};
|
||||
}
|
||||
try {
|
||||
await expect(
|
||||
withPluginRuntimeRegistryScope(inspection.registry, fixture.prepare).then(
|
||||
() => undefined,
|
||||
),
|
||||
).rejects.toThrow("speech directive projection failed");
|
||||
await new Promise<void>((resolve) => {
|
||||
setImmediate(resolve);
|
||||
});
|
||||
expect(fixture.state.connections[0]?.database.isOpen).toBe(true);
|
||||
const preparation = withPluginRuntimeRegistryScope(inspection.registry, fixture.prepare);
|
||||
if (fails) {
|
||||
await expect(preparation).rejects.toThrow("speech directive projection failed");
|
||||
await nextTurn();
|
||||
expect(fixture.state.connections[0]?.database.isOpen).toBe(true);
|
||||
} else {
|
||||
await preparation;
|
||||
expect(fixture.state.tails.length).toBe(1);
|
||||
await inspection.release();
|
||||
}
|
||||
let closed = false;
|
||||
const closing = fixture.host.close().then(() => {
|
||||
closed = true;
|
||||
});
|
||||
await new Promise<void>((resolve) => {
|
||||
setImmediate(resolve);
|
||||
});
|
||||
await nextTurn();
|
||||
expect(closed).toBe(false);
|
||||
expect(fixture.state.connections[0]?.database.isOpen).toBe(true);
|
||||
fixture.state.tailResume.resolve();
|
||||
await Promise.all(fixture.state.tails);
|
||||
await closing;
|
||||
|
|
@ -309,110 +297,58 @@ describe("async speech preparation resources", () => {
|
|||
await inspection.release();
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects projection when its SDK host closes during preparation", async () => {
|
||||
const fixture = createPreparationFixture();
|
||||
try {
|
||||
await fixture.withEnvironment(async () => {
|
||||
useNoBundledPlugins();
|
||||
const inspection = await acquirePluginRegistryForInspection({ config: fixture.cfg });
|
||||
let closing: Promise<void> | undefined;
|
||||
fixture.state.callbacks.onProjection = () => {
|
||||
closing ??= fixture.host.close();
|
||||
};
|
||||
try {
|
||||
await expect(
|
||||
withPluginRuntimeRegistryScope(inspection.registry, fixture.prepare),
|
||||
).rejects.toThrow("Plugin SDK resource host is closed");
|
||||
await closing;
|
||||
await inspection.release();
|
||||
expect(fixture.state.connections[0]?.disposals).toBe(1);
|
||||
} finally {
|
||||
await inspection.release();
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("joins tracked directive work before closing borrowed SQLite resources", async () => {
|
||||
const fixture = createPreparationFixture();
|
||||
try {
|
||||
await fixture.withEnvironment(async () => {
|
||||
useNoBundledPlugins();
|
||||
const inspection = await acquirePluginRegistryForInspection({ config: fixture.cfg });
|
||||
fixture.state.callbacks.trackTail = true;
|
||||
try {
|
||||
await withPluginRuntimeRegistryScope(inspection.registry, fixture.prepare);
|
||||
expect(fixture.state.tails.length).toBe(1);
|
||||
await inspection.release();
|
||||
let closed = false;
|
||||
const closing = fixture.host.close().then(() => {
|
||||
closed = true;
|
||||
});
|
||||
await new Promise<void>((resolve) => {
|
||||
setImmediate(resolve);
|
||||
});
|
||||
expect(closed).toBe(false);
|
||||
expect(fixture.state.connections[0]?.database.isOpen).toBe(true);
|
||||
fixture.state.tailResume.resolve();
|
||||
await Promise.all(fixture.state.tails);
|
||||
await closing;
|
||||
expect(fixture.state.connections[0]?.disposals).toBe(1);
|
||||
} finally {
|
||||
fixture.state.tailResume.resolve();
|
||||
await Promise.allSettled(fixture.state.tails);
|
||||
await inspection.release();
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
await fixture.runTest(async () => {
|
||||
const inspection = await acquirePluginRegistryForInspection({ config: fixture.cfg });
|
||||
let closing: Promise<void> | undefined;
|
||||
fixture.state.callbacks.onProjection = () => {
|
||||
closing ??= fixture.host.close();
|
||||
};
|
||||
try {
|
||||
await expect(
|
||||
withPluginRuntimeRegistryScope(inspection.registry, fixture.prepare),
|
||||
).rejects.toThrow("Plugin SDK resource host is closed");
|
||||
await closing;
|
||||
await inspection.release();
|
||||
expect(fixture.state.connections[0]?.disposals).toBe(1);
|
||||
} finally {
|
||||
await inspection.release();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["active", "cold"] as const)(
|
||||
"preserves %s raw registration lifetime and cache reuse",
|
||||
async (mode) => {
|
||||
const fixture = createPreparationFixture();
|
||||
try {
|
||||
await fixture.withEnvironment(async () => {
|
||||
useNoBundledPlugins();
|
||||
const registry =
|
||||
mode === "active" ? loadPluginRegistryHandle({ config: fixture.cfg }) : undefined;
|
||||
for (let index = 0; index < 4; index++) {
|
||||
const result = await withPluginRuntimeRegistryScope(registry, fixture.prepare);
|
||||
expect(result.directives.hasDirective).toBe(true);
|
||||
}
|
||||
expect(fixture.state.connections.length).toBe(1);
|
||||
await fixture.host.close();
|
||||
expect(fixture.state.connections[0]?.database.isOpen).toBe(true);
|
||||
expect(fixture.state.connections[0]?.disposals).toBe(0);
|
||||
});
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
await fixture.runTest(async () => {
|
||||
const registry =
|
||||
mode === "active" ? loadPluginRegistryHandle({ config: fixture.cfg }) : undefined;
|
||||
for (let index = 0; index < 4; index++) {
|
||||
const result = await withPluginRuntimeRegistryScope(registry, fixture.prepare);
|
||||
expect(result.directives.hasDirective).toBe(true);
|
||||
}
|
||||
expect(fixture.state.connections.length).toBe(1);
|
||||
await fixture.host.close();
|
||||
expect(fixture.state.connections[0]?.database.isOpen).toBe(true);
|
||||
expect(fixture.state.connections[0]?.disposals).toBe(0);
|
||||
});
|
||||
expect(fixture.state.connections[0]?.cleanups).toBe(1);
|
||||
},
|
||||
);
|
||||
|
||||
it("rejects new preparation after the SDK host closes", async () => {
|
||||
const fixture = createPreparationFixture();
|
||||
try {
|
||||
await fixture.withEnvironment(async () => {
|
||||
useNoBundledPlugins();
|
||||
await fixture.host.close();
|
||||
await expect(fixture.prepare().then(() => undefined)).rejects.toThrow(
|
||||
"Plugin SDK resource host is closed",
|
||||
);
|
||||
expect(fixture.state.connections.length).toBe(0);
|
||||
});
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
await fixture.runTest(async () => {
|
||||
await fixture.host.close();
|
||||
await expect(fixture.prepare().then(() => undefined)).rejects.toThrow(
|
||||
"Plugin SDK resource host is closed",
|
||||
);
|
||||
expect(fixture.state.connections.length).toBe(0);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ function createFixture(
|
|||
explicitRelease?: boolean;
|
||||
ending?: "eof" | "error" | "blocked";
|
||||
initialState?: "empty" | "error" | "chunk";
|
||||
projectionFailure?: "result" | "config-model" | "config-voice" | "audioStream" | "release";
|
||||
projectionFailure?: "result" | "config-model" | "audioStream" | "release";
|
||||
holdRelease?: boolean;
|
||||
invalidMetadata?: boolean;
|
||||
releaseFailure?: boolean;
|
||||
|
|
@ -108,8 +108,8 @@ module.exports = { id: ${JSON.stringify(id)}, register(api) {
|
|||
record("release"); state.released.resolve(); state.aborted.resolve();
|
||||
await state.releaseResume.promise; record("released"); if (state.options.releaseFailure) throw new Error("native cleanup failure");
|
||||
})();
|
||||
if (state.options.projectionFailure?.startsWith("config-")) {
|
||||
Object.defineProperty(request.providerConfig, state.options.projectionFailure === "config-model" ? "modelId" : "speakerVoiceId", { get() { throw new Error("native config projection failure"); } });
|
||||
if (state.options.projectionFailure === "config-model") {
|
||||
Object.defineProperty(request.providerConfig, "modelId", { get() { throw new Error("native config projection failure"); } });
|
||||
}
|
||||
if (["audioStream", "release"].includes(state.options.projectionFailure)) {
|
||||
state.work.push(state.trackAsyncWork(async () => { await state.aborted.promise; record("capture-tail"); }));
|
||||
|
|
@ -148,18 +148,23 @@ module.exports = { id: ${JSON.stringify(id)}, register(api) {
|
|||
plugin,
|
||||
state,
|
||||
run,
|
||||
withEnvironment: (operation: () => Promise<void>) =>
|
||||
withEnvAsync(
|
||||
{
|
||||
OPENCLAW_HOME: dir,
|
||||
OPENCLAW_STATE_DIR: dir,
|
||||
OPENCLAW_CONFIG_PATH: path.join(dir, "config.json"),
|
||||
},
|
||||
async () => {
|
||||
useNoBundledPlugins();
|
||||
await operation();
|
||||
},
|
||||
),
|
||||
async runTest(operation: () => Promise<void>) {
|
||||
try {
|
||||
await withEnvAsync(
|
||||
{
|
||||
OPENCLAW_HOME: dir,
|
||||
OPENCLAW_STATE_DIR: dir,
|
||||
OPENCLAW_CONFIG_PATH: path.join(dir, "config.json"),
|
||||
},
|
||||
async () => {
|
||||
useNoBundledPlugins();
|
||||
await operation();
|
||||
},
|
||||
);
|
||||
} finally {
|
||||
await this.cleanup();
|
||||
}
|
||||
},
|
||||
async cleanup() {
|
||||
aborted.resolve();
|
||||
tail.resolve();
|
||||
|
|
@ -196,115 +201,77 @@ afterEach(() => {
|
|||
afterAll(cleanupPluginLoaderFixturesForTest);
|
||||
|
||||
describe("streaming speech registration ownership", () => {
|
||||
it.each(["eof", "error"] as const)(
|
||||
"keeps explicit cleanup after %s and closes on release",
|
||||
async (ending) => {
|
||||
const fixture = createFixture({ ending });
|
||||
it.each([
|
||||
{ ending: "eof", explicitRelease: true },
|
||||
{ ending: "error", explicitRelease: true },
|
||||
{ ending: "eof", explicitRelease: false },
|
||||
{ ending: "error", explicitRelease: false },
|
||||
] as const)("closes after $ending with explicit release=$explicitRelease", async (options) => {
|
||||
const fixture = createFixture(options);
|
||||
await fixture.runTest(async () => {
|
||||
const result = await fixture.run();
|
||||
expect(result.success).toBe(true);
|
||||
const reader = result.audioStream!.getReader();
|
||||
try {
|
||||
await fixture.withEnvironment(async () => {
|
||||
const result = await fixture.run();
|
||||
expect(result.success).toBe(true);
|
||||
const reader = result.audioStream!.getReader();
|
||||
try {
|
||||
expect((await reader.read()).value).toEqual(pcm);
|
||||
if (ending === "error") {
|
||||
await expect(reader.read()).rejects.toThrow("native read failure");
|
||||
} else {
|
||||
expect((await reader.read()).done).toBe(true);
|
||||
}
|
||||
expect(fixture.state.events.some((event) => event.phase === "release")).toBe(false);
|
||||
expect(fixture.state.connections.every((entry) => entry.database.isOpen)).toBe(true);
|
||||
await result.release?.();
|
||||
await result.release?.();
|
||||
expect(fixture.state.events.filter((event) => event.phase === "release").length).toBe(
|
||||
1,
|
||||
);
|
||||
expectClosed(fixture);
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
await result.release?.();
|
||||
}
|
||||
});
|
||||
expect((await reader.read()).value).toEqual(pcm);
|
||||
if (options.ending === "error") {
|
||||
await expect(reader.read()).rejects.toThrow("native read failure");
|
||||
} else {
|
||||
expect((await reader.read()).done).toBe(true);
|
||||
}
|
||||
if (options.explicitRelease) {
|
||||
expect(fixture.state.events.some((event) => event.phase === "release")).toBe(false);
|
||||
expect(fixture.state.connections.every((entry) => entry.database.isOpen)).toBe(true);
|
||||
await result.release?.();
|
||||
await result.release?.();
|
||||
expect(fixture.state.events.filter((event) => event.phase === "release").length).toBe(1);
|
||||
}
|
||||
expectClosed(fixture);
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
reader.releaseLock();
|
||||
await result.release?.();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["eof", "error"] as const)(
|
||||
"releases a provider with no release callback after %s",
|
||||
async (ending) => {
|
||||
const fixture = createFixture({ ending, explicitRelease: false });
|
||||
try {
|
||||
await fixture.withEnvironment(async () => {
|
||||
const result = await fixture.run();
|
||||
const reader = result.audioStream!.getReader();
|
||||
try {
|
||||
expect((await reader.read()).value).toEqual(pcm);
|
||||
if (ending === "error") {
|
||||
await expect(reader.read()).rejects.toThrow("native read failure");
|
||||
} else {
|
||||
expect((await reader.read()).done).toBe(true);
|
||||
}
|
||||
expectClosed(fixture);
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
await result.release?.();
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
},
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
it("releases an unopened stream without requiring a reader", async () => {
|
||||
const fixture = createFixture();
|
||||
try {
|
||||
await fixture.withEnvironment(async () => {
|
||||
const result = await fixture.run();
|
||||
await result.release?.();
|
||||
expect(fixture.state.events.some((event) => event.phase === "pull")).toBe(false);
|
||||
expectClosed(fixture);
|
||||
});
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
await fixture.runTest(async () => {
|
||||
const result = await fixture.run();
|
||||
await result.release?.();
|
||||
expect(fixture.state.events.some((event) => event.phase === "pull")).toBe(false);
|
||||
expectClosed(fixture);
|
||||
});
|
||||
});
|
||||
|
||||
it.each(["managed", "raw"] as const)(
|
||||
"preserves %s registration custody after stream handoff",
|
||||
async (kind) => {
|
||||
const fixture = createFixture();
|
||||
try {
|
||||
await fixture.withEnvironment(async () => {
|
||||
const inspection =
|
||||
kind === "managed"
|
||||
? await acquirePluginRegistryForInspection({ config: fixture.cfg })
|
||||
: undefined;
|
||||
const registry =
|
||||
inspection?.registry ?? loadPluginRegistryHandle({ config: fixture.cfg });
|
||||
const result = await withPluginRuntimeRegistryScope(registry, fixture.run);
|
||||
await inspection?.release();
|
||||
const reader = result.audioStream!.getReader();
|
||||
try {
|
||||
expect((await reader.read()).value).toEqual(pcm);
|
||||
expect((await reader.read()).done).toBe(true);
|
||||
await result.release?.();
|
||||
if (kind === "managed") {
|
||||
expectClosed(fixture);
|
||||
} else {
|
||||
expect(fixture.state.connections.every((entry) => entry.database.isOpen)).toBe(true);
|
||||
expect(fixture.state.connections.every((entry) => entry.disposals === 0)).toBe(true);
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
await result.release?.();
|
||||
await fixture.runTest(async () => {
|
||||
const inspection =
|
||||
kind === "managed"
|
||||
? await acquirePluginRegistryForInspection({ config: fixture.cfg })
|
||||
: undefined;
|
||||
const registry = inspection?.registry ?? loadPluginRegistryHandle({ config: fixture.cfg });
|
||||
const result = await withPluginRuntimeRegistryScope(registry, fixture.run);
|
||||
await inspection?.release();
|
||||
const reader = result.audioStream!.getReader();
|
||||
try {
|
||||
expect((await reader.read()).value).toEqual(pcm);
|
||||
expect((await reader.read()).done).toBe(true);
|
||||
await result.release?.();
|
||||
if (kind === "managed") {
|
||||
expectClosed(fixture);
|
||||
} else {
|
||||
expect(fixture.state.connections.every((entry) => entry.database.isOpen)).toBe(true);
|
||||
expect(fixture.state.connections.every((entry) => entry.disposals === 0)).toBe(true);
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
await result.release?.();
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
|
|
@ -312,54 +279,50 @@ describe("streaming speech registration ownership", () => {
|
|||
"%s aborts before joining the blocked pull and retains its actual tail/context",
|
||||
async (method) => {
|
||||
const fixture = createFixture({ ending: "blocked" });
|
||||
try {
|
||||
await fixture.withEnvironment(async () => {
|
||||
const result = await fixture.run();
|
||||
const reader = result.audioStream!.getReader();
|
||||
const read = reader.read();
|
||||
await fixture.state.started.promise;
|
||||
let settled = false;
|
||||
const closing = fixture.state.context
|
||||
.run("foreign", () => (method === "cancel" ? reader.cancel("stop") : result.release!()))
|
||||
.then(() => {
|
||||
settled = true;
|
||||
});
|
||||
try {
|
||||
await nextTurn();
|
||||
expect(fixture.state.events.some((event) => event.phase === "release")).toBe(true);
|
||||
expect(settled).toBe(false);
|
||||
expect(fixture.state.connections.every((entry) => entry.database.isOpen)).toBe(true);
|
||||
fixture.state.tail.resolve();
|
||||
await closing;
|
||||
await read;
|
||||
expect(
|
||||
fixture.state.events
|
||||
.filter((event) => ["cancel", "release", "tail"].includes(event.phase))
|
||||
.every((event) => event.context === "source"),
|
||||
).toBe(true);
|
||||
expectClosed(fixture);
|
||||
} finally {
|
||||
fixture.state.aborted.resolve();
|
||||
fixture.state.tail.resolve();
|
||||
await closing;
|
||||
await read;
|
||||
reader.releaseLock();
|
||||
await result.release?.();
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
await fixture.runTest(async () => {
|
||||
const result = await fixture.run();
|
||||
const reader = result.audioStream!.getReader();
|
||||
const read = reader.read();
|
||||
await fixture.state.started.promise;
|
||||
let settled = false;
|
||||
const closing = fixture.state.context
|
||||
.run("foreign", () => (method === "cancel" ? reader.cancel("stop") : result.release!()))
|
||||
.then(() => {
|
||||
settled = true;
|
||||
});
|
||||
try {
|
||||
await nextTurn();
|
||||
expect(fixture.state.events.some((event) => event.phase === "release")).toBe(true);
|
||||
expect(settled).toBe(false);
|
||||
expect(fixture.state.connections.every((entry) => entry.database.isOpen)).toBe(true);
|
||||
fixture.state.tail.resolve();
|
||||
await closing;
|
||||
await read;
|
||||
expect(
|
||||
fixture.state.events
|
||||
.filter((event) => ["cancel", "release", "tail"].includes(event.phase))
|
||||
.every((event) => event.context === "source"),
|
||||
).toBe(true);
|
||||
expectClosed(fixture);
|
||||
} finally {
|
||||
fixture.state.aborted.resolve();
|
||||
fixture.state.tail.resolve();
|
||||
await closing;
|
||||
await read;
|
||||
reader.releaseLock();
|
||||
await result.release?.();
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["result", "config-model", "config-voice"] as const)(
|
||||
it.each(["result", "config-model"] as const)(
|
||||
"closes a stream when %s metadata projection fails before fallback",
|
||||
async (projectionFailure) => {
|
||||
const primary = createFixture({ id: "primary-stream", projectionFailure, holdRelease: true });
|
||||
const fallback = createFixture({ id: "fallback-stream" });
|
||||
try {
|
||||
await primary.withEnvironment(async () => {
|
||||
await primary.runTest(async () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
...primary.cfg,
|
||||
plugins: {
|
||||
|
|
@ -397,31 +360,26 @@ describe("streaming speech registration ownership", () => {
|
|||
}
|
||||
});
|
||||
} finally {
|
||||
await primary.cleanup();
|
||||
await fallback.cleanup();
|
||||
}
|
||||
},
|
||||
);
|
||||
it("releases a successful provider result rejected by the public metadata guard", async () => {
|
||||
const fixture = createFixture({ invalidMetadata: true });
|
||||
try {
|
||||
await fixture.withEnvironment(async () => {
|
||||
const result = await fixture.run();
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe("Streaming TTS conversion failed");
|
||||
expect(fixture.state.events.filter((event) => event.phase === "release").length).toBe(1);
|
||||
expectClosed(fixture);
|
||||
});
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
await fixture.runTest(async () => {
|
||||
const result = await fixture.run();
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toBe("Streaming TTS conversion failed");
|
||||
expect(fixture.state.events.filter((event) => event.phase === "release").length).toBe(1);
|
||||
expectClosed(fixture);
|
||||
});
|
||||
});
|
||||
|
||||
it("keeps a post-byte stream failure with the selected provider", async () => {
|
||||
const primary = createFixture({ id: "primary-stream", ending: "error" });
|
||||
const fallback = createFixture({ id: "fallback-stream" });
|
||||
try {
|
||||
await primary.withEnvironment(async () => {
|
||||
await primary.runTest(async () => {
|
||||
const cfg: OpenClawConfig = {
|
||||
...primary.cfg,
|
||||
plugins: {
|
||||
|
|
@ -447,24 +405,19 @@ describe("streaming speech registration ownership", () => {
|
|||
}
|
||||
});
|
||||
} finally {
|
||||
await primary.cleanup();
|
||||
await fallback.cleanup();
|
||||
}
|
||||
});
|
||||
|
||||
it("preserves the projection error when its stream cleanup also fails", async () => {
|
||||
const fixture = createFixture({ projectionFailure: "result", releaseFailure: true });
|
||||
try {
|
||||
await fixture.withEnvironment(async () => {
|
||||
const result = await fixture.run();
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("native result projection failure");
|
||||
expect(result.error).not.toContain("native cleanup failure");
|
||||
expectClosed(fixture);
|
||||
});
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
await fixture.runTest(async () => {
|
||||
const result = await fixture.run();
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain("native result projection failure");
|
||||
expect(result.error).not.toContain("native cleanup failure");
|
||||
expectClosed(fixture);
|
||||
});
|
||||
});
|
||||
it.each([
|
||||
{ projectionFailure: "audioStream", releaseFailure: false },
|
||||
|
|
@ -474,64 +427,56 @@ describe("streaming speech registration ownership", () => {
|
|||
"uses the other cleanup field when $projectionFailure fails (cleanup rejects: $releaseFailure)",
|
||||
async ({ projectionFailure, releaseFailure }) => {
|
||||
const fixture = createFixture({ projectionFailure, releaseFailure });
|
||||
try {
|
||||
await fixture.withEnvironment(async () => {
|
||||
const pending = fixture.run();
|
||||
try {
|
||||
await fixture.state.started.promise;
|
||||
await nextTurn();
|
||||
expect(
|
||||
fixture.state.events.some(
|
||||
(event) =>
|
||||
event.phase === (projectionFailure === "audioStream" ? "release" : "cancel"),
|
||||
),
|
||||
).toBe(true);
|
||||
const result = await pending;
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain(`native ${projectionFailure} projection failure`);
|
||||
expect(fixture.state.events.some((event) => event.phase === "capture-tail")).toBe(true);
|
||||
expectClosed(fixture);
|
||||
} finally {
|
||||
fixture.state.aborted.resolve();
|
||||
await pending;
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
await fixture.runTest(async () => {
|
||||
const pending = fixture.run();
|
||||
try {
|
||||
await fixture.state.started.promise;
|
||||
await nextTurn();
|
||||
expect(
|
||||
fixture.state.events.some(
|
||||
(event) =>
|
||||
event.phase === (projectionFailure === "audioStream" ? "release" : "cancel"),
|
||||
),
|
||||
).toBe(true);
|
||||
const result = await pending;
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.error).toContain(`native ${projectionFailure} projection failure`);
|
||||
expect(fixture.state.events.some((event) => event.phase === "capture-tail")).toBe(true);
|
||||
expectClosed(fixture);
|
||||
} finally {
|
||||
fixture.state.aborted.resolve();
|
||||
await pending;
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
it.each(["empty", "error", "chunk"] as const)(
|
||||
"finishes a no-release source in initial state %s without an extra EOF read",
|
||||
async (initialState) => {
|
||||
const fixture = createFixture({ explicitRelease: false, initialState });
|
||||
try {
|
||||
await fixture.withEnvironment(async () => {
|
||||
const result = await fixture.run();
|
||||
const reader = result.audioStream!.getReader();
|
||||
try {
|
||||
if (initialState === "chunk") {
|
||||
expect((await reader.read()).value).toEqual(pcm);
|
||||
}
|
||||
if (initialState === "error") {
|
||||
await expect(reader.closed).rejects.toThrow("native initial failure");
|
||||
} else {
|
||||
await expect(reader.closed).resolves.toBeUndefined();
|
||||
}
|
||||
expectClosed(fixture);
|
||||
if (initialState === "error") {
|
||||
await expect(reader.read()).rejects.toThrow("native initial failure");
|
||||
} else {
|
||||
expect((await reader.read()).done).toBe(true);
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
await result.release?.();
|
||||
await fixture.runTest(async () => {
|
||||
const result = await fixture.run();
|
||||
const reader = result.audioStream!.getReader();
|
||||
try {
|
||||
if (initialState === "chunk") {
|
||||
expect((await reader.read()).value).toEqual(pcm);
|
||||
}
|
||||
});
|
||||
} finally {
|
||||
await fixture.cleanup();
|
||||
}
|
||||
if (initialState === "error") {
|
||||
await expect(reader.closed).rejects.toThrow("native initial failure");
|
||||
} else {
|
||||
await expect(reader.closed).resolves.toBeUndefined();
|
||||
}
|
||||
expectClosed(fixture);
|
||||
if (initialState === "error") {
|
||||
await expect(reader.read()).rejects.toThrow("native initial failure");
|
||||
} else {
|
||||
expect((await reader.read()).done).toBe(true);
|
||||
}
|
||||
} finally {
|
||||
reader.releaseLock();
|
||||
await result.release?.();
|
||||
}
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -342,31 +342,26 @@ afterEach(async () => {
|
|||
});
|
||||
afterAll(cleanupPluginLoaderFixturesForTest);
|
||||
|
||||
it.each([false, true])(
|
||||
"owns the selected summary model through completion and cleanup (tail=%s)",
|
||||
async (tail) => {
|
||||
const fixture = nativeSummaryFixture();
|
||||
await fixture.run(async () => {
|
||||
fixture.state.completionTail = tail;
|
||||
fixture.state.finish.resolve();
|
||||
const outcome = await fixture.summarize();
|
||||
expect(fixture.state.connections[0]?.openAtCompletion).toBe(true);
|
||||
expect(outcome).toMatchObject({
|
||||
result: { summary: "Spoken summary 42." },
|
||||
});
|
||||
expect(fixture.state.calls.map((call) => call.model)).toEqual(["summary-model"]);
|
||||
if (tail) {
|
||||
await setImmediate();
|
||||
expect(fixture.state.connections[0]?.database.isOpen).toBe(true);
|
||||
fixture.state.finishTail.resolve();
|
||||
}
|
||||
await fixture.assertClosed();
|
||||
expect(fixture.state.connections[0]?.completionReads).toBe(1);
|
||||
expect(fixture.state.connections[0]?.cleanupReads).toBe(tail ? 1 : 0);
|
||||
expect(fixture.state.cancellation.failure).toBeUndefined();
|
||||
it("owns the selected summary model through completion and its cleanup tail", async () => {
|
||||
const fixture = nativeSummaryFixture();
|
||||
await fixture.run(async () => {
|
||||
fixture.state.completionTail = true;
|
||||
fixture.state.finish.resolve();
|
||||
const outcome = await fixture.summarize();
|
||||
expect(fixture.state.connections[0]?.openAtCompletion).toBe(true);
|
||||
expect(outcome).toMatchObject({
|
||||
result: { summary: "Spoken summary 42." },
|
||||
});
|
||||
},
|
||||
);
|
||||
expect(fixture.state.calls.map((call) => call.model)).toEqual(["summary-model"]);
|
||||
await setImmediate();
|
||||
expect(fixture.state.connections[0]?.database.isOpen).toBe(true);
|
||||
fixture.state.finishTail.resolve();
|
||||
await fixture.assertClosed();
|
||||
expect(fixture.state.connections[0]?.completionReads).toBe(1);
|
||||
expect(fixture.state.connections[0]?.cleanupReads).toBe(1);
|
||||
expect(fixture.state.cancellation.failure).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
it("starts the summary deadline after preparation and keeps an uncooperative provider owned", async () => {
|
||||
const fixture = nativeSummaryFixture();
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { createServer } from "node:http";
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
import { afterEach, expect, it, vi } from "vitest";
|
||||
import { requireApiKey } from "../agents/model-auth.js";
|
||||
import { acquireAgentRunPreparedModelRuntime } from "../agents/prepared-model-runtime.js";
|
||||
import { resetPreparedModelRuntimeSnapshotsForTest } from "../agents/prepared-model-runtime.test-support.js";
|
||||
|
|
@ -203,62 +203,36 @@ function summaryRequest(cfg: OpenClawConfig) {
|
|||
};
|
||||
}
|
||||
|
||||
describe.each([undefined, "openai-completions"] as const)(
|
||||
"unscoped summary selection with provider API %s",
|
||||
(api) => {
|
||||
it.each([
|
||||
{ name: "explicit override", summaryModel: `${provider}/entry`, expected: "middle" },
|
||||
{ name: "global bare alias", summaryModel: "fast", expected: "middle" },
|
||||
{ name: "global qualified alias", summaryModel: `${provider}/fast`, expected: "middle" },
|
||||
{ name: "bare literal", summaryModel: "entry", expected: "middle" },
|
||||
{ name: "missing override", primary: `${provider}/entry`, expected: "middle" },
|
||||
{ name: "bare global default", primary: "fast", expected: "middle" },
|
||||
{ name: "invalid override", summaryModel: "/", primary: "fast", expected: "middle" },
|
||||
{ name: "profile suffix", summaryModel: "fast@work", expected: "middle" },
|
||||
{ name: "raw next alias", summaryModel: `${provider}/middle`, expected: "final" },
|
||||
{ name: "ordinary model", summaryModel: `${provider}/plain`, expected: "plain" },
|
||||
])("uses the global model for $name", async ({ expected, ...options }) => {
|
||||
await withSummaryFixture({ ...options, api }, async (cfg, _state, requests) => {
|
||||
expect(await summarizeText(summaryRequest(cfg))).toMatchObject({
|
||||
summary: `materialized:${expected}`,
|
||||
});
|
||||
expect(requests).toEqual([expected]);
|
||||
it.each([
|
||||
{ name: "explicit override", summaryModel: `${provider}/entry` },
|
||||
{ name: "qualified alias", summaryModel: `${provider}/fast`, api: "openai-completions" },
|
||||
{ name: "bare literal", summaryModel: "entry", api: "openai-completions" },
|
||||
{ name: "missing override", primary: `${provider}/entry` },
|
||||
{ name: "invalid override", summaryModel: "/", primary: "fast", api: "openai-completions" },
|
||||
{ name: "bare alias with profile suffix", summaryModel: "fast@work" },
|
||||
{ name: "bare default without configured rows", primary: "entry", bareDefault: true },
|
||||
{
|
||||
name: "invalid override without configured rows",
|
||||
primary: "entry",
|
||||
summaryModel: "/",
|
||||
bareDefault: true,
|
||||
api: "openai-completions",
|
||||
},
|
||||
{
|
||||
name: "bare override without configured rows",
|
||||
primary: "plain",
|
||||
summaryModel: "entry",
|
||||
bareDefault: true,
|
||||
},
|
||||
] satisfies Array<FixtureOptions & { name: string }>)(
|
||||
"normalizes the global summary selection once for $name",
|
||||
async (options) => {
|
||||
await withSummaryFixture(options, async (cfg, _state, requests) => {
|
||||
expect(await summarizeText(summaryRequest(cfg))).toMatchObject({
|
||||
summary: "materialized:middle",
|
||||
});
|
||||
expect(requests).toEqual(["middle"]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ name: "missing override", primary: "entry", expected: "middle" },
|
||||
{ name: "invalid override", primary: "entry", summaryModel: "/", expected: "middle" },
|
||||
{
|
||||
name: "explicit bare override",
|
||||
primary: "plain",
|
||||
summaryModel: "entry",
|
||||
expected: "middle",
|
||||
},
|
||||
{
|
||||
name: "qualified primary",
|
||||
primary: "openai/entry",
|
||||
expected: "middle",
|
||||
},
|
||||
{ name: "raw next alias", primary: "middle", expected: "final" },
|
||||
{ name: "ordinary primary", primary: "plain", expected: "plain" },
|
||||
])(
|
||||
"uses the default provider without configured rows for $name",
|
||||
async ({ expected, ...options }) => {
|
||||
await withSummaryFixture(
|
||||
{ ...options, api, bareDefault: true },
|
||||
async (cfg, _state, requests) => {
|
||||
expect(cfg.agents?.defaults?.models).toEqual({});
|
||||
expect(cfg.agents?.entries?.main?.models).toEqual({});
|
||||
expect(cfg.models?.providers?.openai?.models).toEqual([]);
|
||||
expect(await summarizeText(summaryRequest(cfg))).toMatchObject({
|
||||
summary: `materialized:${expected}`,
|
||||
});
|
||||
expect(requests).toEqual([expected]);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
|
|
|
|||
|
|
@ -114,47 +114,44 @@ if (endpoint === "repos/openclaw/openclaw/actions/runs/101") {
|
|||
}
|
||||
|
||||
describe("full release child evidence producer", () => {
|
||||
it.each(["normalCi", "pluginPrereleaseIndependent"])(
|
||||
"blocks failed selected metadata and workload jobs for %s",
|
||||
(key) => {
|
||||
const workload = {
|
||||
name: "install_smoke",
|
||||
status: "completed",
|
||||
conclusion: "success",
|
||||
};
|
||||
const metadata = { name: PUBLISHER, status: "completed", conclusion: "failure" };
|
||||
const snapshot = () =>
|
||||
classifyReleaseSnapshot({
|
||||
children: [
|
||||
{
|
||||
key,
|
||||
selected: true,
|
||||
required: true,
|
||||
result: "success",
|
||||
source: "fresh",
|
||||
runId: "101",
|
||||
runAttempt: 1,
|
||||
status: "completed",
|
||||
conclusion: "success",
|
||||
jobs: [workload, metadata],
|
||||
},
|
||||
],
|
||||
releaseProfile: "stable",
|
||||
workflowRef: "main",
|
||||
});
|
||||
expect(snapshot()).toMatchObject({
|
||||
state: "blocked_complete",
|
||||
blockers: [expect.objectContaining({ job: PUBLISHER, kind: "job_failure" })],
|
||||
it.each(["normalCi"])("blocks failed selected metadata and workload jobs for %s", (key) => {
|
||||
const workload = {
|
||||
name: "install_smoke",
|
||||
status: "completed",
|
||||
conclusion: "success",
|
||||
};
|
||||
const metadata = { name: PUBLISHER, status: "completed", conclusion: "failure" };
|
||||
const snapshot = () =>
|
||||
classifyReleaseSnapshot({
|
||||
children: [
|
||||
{
|
||||
key,
|
||||
selected: true,
|
||||
required: true,
|
||||
result: "success",
|
||||
source: "fresh",
|
||||
runId: "101",
|
||||
runAttempt: 1,
|
||||
status: "completed",
|
||||
conclusion: "success",
|
||||
jobs: [workload, metadata],
|
||||
},
|
||||
],
|
||||
releaseProfile: "stable",
|
||||
workflowRef: "main",
|
||||
});
|
||||
metadata.conclusion = "success";
|
||||
expect(snapshot()).toMatchObject({ state: "passed", blockers: [] });
|
||||
workload.conclusion = "failure";
|
||||
expect(snapshot()).toMatchObject({
|
||||
state: "blocked_complete",
|
||||
blockers: [expect.objectContaining({ job: "install_smoke", kind: "job_failure" })],
|
||||
});
|
||||
},
|
||||
);
|
||||
expect(snapshot()).toMatchObject({
|
||||
state: "blocked_complete",
|
||||
blockers: [expect.objectContaining({ job: PUBLISHER, kind: "job_failure" })],
|
||||
});
|
||||
metadata.conclusion = "success";
|
||||
expect(snapshot()).toMatchObject({ state: "passed", blockers: [] });
|
||||
workload.conclusion = "failure";
|
||||
expect(snapshot()).toMatchObject({
|
||||
state: "blocked_complete",
|
||||
blockers: [expect.objectContaining({ job: "install_smoke", kind: "job_failure" })],
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
"ci.yml",
|
||||
|
|
@ -263,7 +260,7 @@ describe("full release child evidence producer", () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it.each([2, 3])(
|
||||
it.each([3])(
|
||||
"recovers publisher-only attempt %s while carrying earlier workload evidence",
|
||||
(runAttempt) => {
|
||||
const data = fixture();
|
||||
|
|
@ -289,7 +286,7 @@ describe("full release child evidence producer", () => {
|
|||
workloadConclusion: "success",
|
||||
effectiveRunAttempt: runAttempt,
|
||||
});
|
||||
expect(evidence.observedRunAttempts).toEqual(runAttempt === 2 ? [1, 2] : [1, 2, 3]);
|
||||
expect(evidence.observedRunAttempts).toEqual([1, 2, 3]);
|
||||
expect(
|
||||
evidence.jobs.map((job: { name: string; acceptedRunAttempt: number }) => [
|
||||
job.name,
|
||||
|
|
|
|||
|
|
@ -296,11 +296,7 @@ describe("independent release child reuse", () => {
|
|||
expect.stringContaining('scanned 7 runs, evaluated 1; skipped {"target receipt absent":6}'),
|
||||
);
|
||||
});
|
||||
it.each([
|
||||
{ status: "completed", conclusion: "failure" },
|
||||
{ status: "completed", conclusion: "cancelled" },
|
||||
{ status: "in_progress", conclusion: null },
|
||||
])(
|
||||
it.each([{ status: "in_progress", conclusion: null }])(
|
||||
"reuses green children from $status/$conclusion parents without a manifest",
|
||||
async (parentState) => {
|
||||
const data = await fixture();
|
||||
|
|
@ -485,14 +481,13 @@ describe("independent release child reuse", () => {
|
|||
|
||||
it.each([
|
||||
{ target_ref: "c".repeat(40), release_scope: "full" },
|
||||
{ target_ref: TARGET, release_scope: "npm-beta" },
|
||||
{ target_ref: TARGET, release_scope: "" },
|
||||
])("rejects different exact dispatch inputs %#", async (inputs) => {
|
||||
const data = await fixture();
|
||||
expect(await discoverReusableReleaseChild({ ...data.request, inputs }, data.deps)).toBeNull();
|
||||
});
|
||||
|
||||
it.each(['{ "id": "801" }', '{"id":"802"}', ""])(
|
||||
it.each(['{ "id": "801" }', ""])(
|
||||
"requires the exact candidate descriptor bytes %s",
|
||||
async (candidate_artifact_json) => {
|
||||
const data = await fixture("pluginPrereleaseCandidate");
|
||||
|
|
|
|||
|
|
@ -248,17 +248,7 @@ describe("publication dispatch transport", () => {
|
|||
value: { trustedWorkflow: identity, publicationSelection: selection },
|
||||
error: "source-admission envelope requires identity, purpose and selection",
|
||||
},
|
||||
{
|
||||
name: "missing identity",
|
||||
value: { validationPurpose: "publish", publicationSelection: selection },
|
||||
error: "source-admission envelope requires identity, purpose and selection",
|
||||
},
|
||||
{ name: "old flat identity", value: identity, error: "invalid source-admission envelope" },
|
||||
{
|
||||
name: "extra envelope field",
|
||||
value: { ...envelope, extra: true },
|
||||
error: "invalid source-admission envelope",
|
||||
},
|
||||
{
|
||||
name: "extra identity field",
|
||||
value: { ...envelope, trustedWorkflow: { ...identity, extra: true } },
|
||||
|
|
@ -1567,16 +1557,6 @@ describe("FRV observation worker boundary", () => {
|
|||
}),
|
||||
);
|
||||
publicationIt.concurrent.for([
|
||||
[
|
||||
"beta plugin",
|
||||
"2026.9.9-beta.1",
|
||||
"2026.9.9-beta.1",
|
||||
"normal",
|
||||
"beta",
|
||||
"@openclaw/demo-plugin",
|
||||
"npm-absent",
|
||||
true,
|
||||
],
|
||||
["root package", "2026.9.9", "2026.9.9", "normal", "latest", "openclaw", "npm-absent", false],
|
||||
[
|
||||
"core package",
|
||||
|
|
@ -1588,16 +1568,6 @@ describe("FRV observation worker boundary", () => {
|
|||
"npm-absent",
|
||||
false,
|
||||
],
|
||||
[
|
||||
"stable plugin on beta",
|
||||
"2026.9.9",
|
||||
"2026.9.9",
|
||||
"normal",
|
||||
"beta",
|
||||
"@openclaw/demo-plugin",
|
||||
"npm-absent",
|
||||
false,
|
||||
],
|
||||
[
|
||||
"stable plugin on latest",
|
||||
"2026.9.9",
|
||||
|
|
@ -1638,16 +1608,6 @@ describe("FRV observation worker boundary", () => {
|
|||
"npm-absent",
|
||||
true,
|
||||
],
|
||||
[
|
||||
"prepared beta plugin",
|
||||
"2026.9.9-beta.1",
|
||||
"2026.9.9-beta.1",
|
||||
"prepared",
|
||||
"beta",
|
||||
"@openclaw/demo-plugin",
|
||||
"npm-absent",
|
||||
true,
|
||||
],
|
||||
[
|
||||
"prepared npm-only beta plugin",
|
||||
"2026.9.9-beta.1",
|
||||
|
|
@ -1678,16 +1638,6 @@ describe("FRV observation worker boundary", () => {
|
|||
"npm-empty-history",
|
||||
false,
|
||||
],
|
||||
[
|
||||
"stable empty history",
|
||||
"2026.9.9",
|
||||
"2026.9.9",
|
||||
"normal",
|
||||
"latest",
|
||||
"@openclaw/demo-plugin",
|
||||
"npm-empty-history",
|
||||
false,
|
||||
],
|
||||
] as const)(
|
||||
"matches npm bootstrap writer ownership for %s",
|
||||
{ timeout: 30_000 },
|
||||
|
|
@ -1822,8 +1772,6 @@ describe("FRV observation worker boundary", () => {
|
|||
);
|
||||
|
||||
publicationIt.concurrent.for([
|
||||
["npm-absent", "normal", true, "npm", "owner-preparation-and-access"],
|
||||
["npm-absent", "prepared", true, "npm", "owner-preparation-and-access"],
|
||||
["clawhub-absent", "normal", true, "clawhub", "bootstrap-and-owner-access"],
|
||||
["clawhub-absent", "prepared", false, "clawhub", ""],
|
||||
["missing-trust", "normal", true, "clawhub", "publisher-repair"],
|
||||
|
|
@ -1958,23 +1906,6 @@ describe("FRV observation worker boundary", () => {
|
|||
check(result.observations).toBeUndefined();
|
||||
}),
|
||||
);
|
||||
|
||||
publicationIt.concurrent(
|
||||
"uses the existing parser-false runtime for a same-SHA worker",
|
||||
async ({ command: processFixture, expect: check }) =>
|
||||
processFixture.lifetime.run(async () => {
|
||||
const result = await fixture(processFixture, check, { registry: "healthy", sameSha: true });
|
||||
check(result.status, result.stderr).toBe(0);
|
||||
check(result.targetSha).toBe(result.toolingSha);
|
||||
check(result.registryCalls.filter((entry) => entry.kind === "request")).toHaveLength(6);
|
||||
check(result.registryCalls).toContainEqual({
|
||||
kind: "runtime",
|
||||
worker: true,
|
||||
inherited: [],
|
||||
});
|
||||
}),
|
||||
30_000,
|
||||
);
|
||||
});
|
||||
|
||||
describe("FRV publication source admission", () => {
|
||||
|
|
@ -2005,10 +1936,6 @@ describe("FRV publication source admission", () => {
|
|||
);
|
||||
publicationIt.concurrent.for([
|
||||
["2026.9.9", "normal", false],
|
||||
["2026.9.9-1", "normal", false],
|
||||
["2026.9.9", "prepared", false],
|
||||
["2026.9.9-1", "prepared", false],
|
||||
["2026.9.9", "normal", true],
|
||||
["2026.9.9-1", "prepared", true],
|
||||
] as const)(
|
||||
"preserves beta-first %s publication through %s with Windows=%s",
|
||||
|
|
@ -2114,27 +2041,7 @@ describe("FRV publication source admission", () => {
|
|||
const result = await fixture(processFixture, check, { fault: "readme" });
|
||||
check(result.status, result.stderr).toBe(1);
|
||||
check(result.stderr).toContain("README.md must exist");
|
||||
for (const id of ["normal_ci", "prepare_npm_package", "prepare_docker_release"]) {
|
||||
const job = expectDefined(workflow.jobs[id], `${id} job`);
|
||||
check([job.needs].flat()).toContain("resolve_target");
|
||||
check(
|
||||
evaluate(job.if ?? "", {
|
||||
github: { run_attempt: 1 },
|
||||
inputs: { rerun_group: "all" },
|
||||
needs: {
|
||||
resolve_target: {
|
||||
result: result.status === 0 ? "success" : "failure",
|
||||
outputs: {
|
||||
candidate_required: "true",
|
||||
target_version: "2026.9.9",
|
||||
},
|
||||
},
|
||||
plugin_compatibility_readiness: { result: "success" },
|
||||
evidence_reuse: { result: "skipped", outputs: { reuse: "false" } },
|
||||
},
|
||||
}),
|
||||
).toBe(false);
|
||||
}
|
||||
check(result.fact).toBeUndefined();
|
||||
}),
|
||||
30_000,
|
||||
);
|
||||
|
|
@ -2172,7 +2079,7 @@ describe("FRV publication source admission", () => {
|
|||
}),
|
||||
);
|
||||
|
||||
publicationIt.concurrent.for(["diagnostic", "main-qualification", "postpublish-confidence"])(
|
||||
publicationIt.concurrent.for(["diagnostic"])(
|
||||
"retains %s without publication bootstrap or added installation",
|
||||
{ timeout: 30_000 },
|
||||
async (purpose, { command: processFixture, expect: check }) =>
|
||||
|
|
@ -2225,7 +2132,6 @@ describe("FRV publication source admission", () => {
|
|||
publicationIt.concurrent.for([
|
||||
["2026.9.9-beta.1", "normal", "beta", "release/2026.9.9", false],
|
||||
["2026.9.9", "normal", "latest", "release/2026.9.9", true],
|
||||
["2026.9.9", "normal", "beta", "v2026.9.9", true],
|
||||
["2026.9.9-1", "normal", "beta", "v2026.9.9-1", true],
|
||||
] as const)(
|
||||
"matches actual Windows publication selection for %s through %s to %s",
|
||||
|
|
@ -2352,7 +2258,6 @@ describe("FRV publication source admission", () => {
|
|||
|
||||
publicationIt.concurrent.for([
|
||||
["2026.9.9-beta.1", "normal", "beta", "release/2026.9.9"],
|
||||
["2026.9.9", "prepared", "latest", "release/2026.9.9"],
|
||||
["2026.8.33", "extended-stable", "extended-stable", "extended-stable/2026.8.33"],
|
||||
])(
|
||||
"admits %s through the existing %s source policy",
|
||||
|
|
@ -2371,14 +2276,7 @@ describe("FRV publication source admission", () => {
|
|||
check(result.fact?.projection?.version).toBe(version);
|
||||
check(result.fact?.targetContextRef).toBe(targetContextRef);
|
||||
const platforms = expectDefined(result.fact?.projection?.platforms, "source platforms");
|
||||
if (version === "2026.9.9") {
|
||||
check(platforms).toContainEqual({
|
||||
id: "linux",
|
||||
source: ".github/workflows/linux-app-release-request.yml",
|
||||
});
|
||||
} else {
|
||||
check(platforms).not.toContainEqual(expect.objectContaining({ id: "linux" }));
|
||||
}
|
||||
check(platforms).not.toContainEqual(expect.objectContaining({ id: "linux" }));
|
||||
if (route === "extended-stable") {
|
||||
check(result.fact?.projection?.packages).toEqual(
|
||||
expect.arrayContaining([{ name: "@openclaw/demo-plugin", version, targets: ["npm"] }]),
|
||||
|
|
@ -2558,10 +2456,7 @@ describe("publication source intent and durable binding", () => {
|
|||
);
|
||||
|
||||
publicationIt.each([
|
||||
["vbad", false],
|
||||
["latest", false],
|
||||
["v0.5", false],
|
||||
["v0.5.0", true],
|
||||
["v0.5.0-rc.1", true],
|
||||
])("validates Windows source tag %s using its native version contract", (tag, valid) => {
|
||||
const normalize = () =>
|
||||
|
|
@ -2577,10 +2472,8 @@ describe("publication source intent and durable binding", () => {
|
|||
});
|
||||
|
||||
publicationIt.each([
|
||||
["", ""],
|
||||
["unknown", ""],
|
||||
["publish", ""],
|
||||
["diagnostic", JSON.stringify(selection)],
|
||||
["publish", JSON.stringify({ ...selection, extra: true })],
|
||||
["publish", JSON.stringify({ ...selection, pluginPublishScope: "selected" })],
|
||||
["publish", JSON.stringify({ ...selection, route: "prepared", publishOpenclawNpm: false })],
|
||||
|
|
@ -2588,15 +2481,6 @@ describe("publication source intent and durable binding", () => {
|
|||
expect(() => normalizePublicationIntent(purpose, value)).toThrow();
|
||||
});
|
||||
|
||||
publicationIt("keeps canonical reusable intent free of per-parent identities", () => {
|
||||
expect(
|
||||
publicationIntentInputs(normalizePublicationIntent("publish", JSON.stringify(selection))),
|
||||
).toEqual({
|
||||
validationPurpose: "publish",
|
||||
publicationSelectionJson: publicationSourceJson(selection),
|
||||
});
|
||||
});
|
||||
|
||||
publicationIt(
|
||||
"requires exact workflow capability and rejects missing or relabeled new evidence",
|
||||
() => {
|
||||
|
|
|
|||
|
|
@ -3,50 +3,48 @@ import { EventEmitter } from "node:events";
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import { stopChild } from "../../scripts/lib/gateway-bench-child.ts";
|
||||
|
||||
function childFixture() {
|
||||
const state: { exitCode: number | null; signalCode: NodeJS.Signals | null } = {
|
||||
exitCode: null,
|
||||
signalCode: null,
|
||||
};
|
||||
return Object.assign(new EventEmitter(), state, { kill: vi.fn(() => true) });
|
||||
}
|
||||
|
||||
function pipedChildFixture() {
|
||||
return Object.assign(childFixture(), {
|
||||
stderr: { destroy: vi.fn() },
|
||||
stdin: { destroy: vi.fn() },
|
||||
stdout: { destroy: vi.fn() },
|
||||
unref: vi.fn(),
|
||||
});
|
||||
}
|
||||
|
||||
describe("gateway benchmark child teardown", () => {
|
||||
it.each([0, 7])(
|
||||
"classifies queued child exit %i before sending teardown signals",
|
||||
async (exitCode) => {
|
||||
const child = new EventEmitter() as EventEmitter & {
|
||||
exitCode: number | null;
|
||||
kill: ReturnType<typeof vi.fn>;
|
||||
signalCode: NodeJS.Signals | null;
|
||||
};
|
||||
child.exitCode = null;
|
||||
child.signalCode = null;
|
||||
child.kill = vi.fn(() => true);
|
||||
|
||||
const stopped = stopChild(child as unknown as ChildProcess);
|
||||
queueMicrotask(() => {
|
||||
child.exitCode = exitCode;
|
||||
child.emit("exit", exitCode, null);
|
||||
});
|
||||
|
||||
await expect(stopped).resolves.toEqual({
|
||||
exitedBeforeTeardown: true,
|
||||
exitCode,
|
||||
signal: null,
|
||||
});
|
||||
expect(child.kill).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
it("classifies a queued child failure before sending teardown signals", async () => {
|
||||
const child = childFixture();
|
||||
const stopped = stopChild(child as unknown as ChildProcess);
|
||||
queueMicrotask(() => {
|
||||
child.exitCode = 7;
|
||||
child.emit("exit", 7, null);
|
||||
});
|
||||
await expect(stopped).resolves.toEqual({
|
||||
exitedBeforeTeardown: true,
|
||||
exitCode: 7,
|
||||
signal: null,
|
||||
});
|
||||
expect(child.kill).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("classifies failed teardown signaling as a pre-teardown child exit", async () => {
|
||||
const child = new EventEmitter() as EventEmitter & {
|
||||
exitCode: number | null;
|
||||
kill: ReturnType<typeof vi.fn>;
|
||||
signalCode: NodeJS.Signals | null;
|
||||
};
|
||||
child.exitCode = null;
|
||||
child.signalCode = null;
|
||||
child.kill = vi.fn(() => {
|
||||
const child = childFixture();
|
||||
child.kill.mockImplementation(() => {
|
||||
setImmediate(() => {
|
||||
child.exitCode = 8;
|
||||
child.emit("exit", 8, null);
|
||||
});
|
||||
return false;
|
||||
});
|
||||
|
||||
await expect(stopChild(child as unknown as ChildProcess)).resolves.toEqual({
|
||||
exitedBeforeTeardown: true,
|
||||
exitCode: 8,
|
||||
|
|
@ -56,30 +54,9 @@ describe("gateway benchmark child teardown", () => {
|
|||
});
|
||||
|
||||
it("bounds teardown and releases IPC when the child ignores termination signals", async () => {
|
||||
const child = new EventEmitter() as EventEmitter & {
|
||||
channel: { unref: ReturnType<typeof vi.fn> };
|
||||
exitCode: number | null;
|
||||
kill: ReturnType<typeof vi.fn>;
|
||||
signalCode: NodeJS.Signals | null;
|
||||
stderr: { destroy: ReturnType<typeof vi.fn> };
|
||||
stdin: { destroy: ReturnType<typeof vi.fn> };
|
||||
stdout: { destroy: ReturnType<typeof vi.fn> };
|
||||
unref: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
child.exitCode = null;
|
||||
child.signalCode = null;
|
||||
child.channel = { unref: vi.fn() };
|
||||
child.kill = vi.fn(() => true);
|
||||
child.stderr = { destroy: vi.fn() };
|
||||
child.stdin = { destroy: vi.fn() };
|
||||
child.stdout = { destroy: vi.fn() };
|
||||
child.unref = vi.fn();
|
||||
|
||||
const child = Object.assign(pipedChildFixture(), { channel: { unref: vi.fn() } });
|
||||
await expect(
|
||||
stopChild(child as unknown as ChildProcess, {
|
||||
killGraceMs: 1,
|
||||
teardownGraceMs: 1,
|
||||
}),
|
||||
stopChild(child as unknown as ChildProcess, { killGraceMs: 1, teardownGraceMs: 1 }),
|
||||
).resolves.toEqual({
|
||||
exitedBeforeTeardown: false,
|
||||
exitCode: null,
|
||||
|
|
@ -94,31 +71,24 @@ describe("gateway benchmark child teardown", () => {
|
|||
expect(child.unref).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"preserves pre-teardown wrapper exits while cleaning the process group",
|
||||
async () => {
|
||||
const child = new EventEmitter() as EventEmitter & {
|
||||
exitCode: number | null;
|
||||
kill: ReturnType<typeof vi.fn>;
|
||||
pid: number;
|
||||
signalCode: NodeJS.Signals | null;
|
||||
stderr: { destroy: ReturnType<typeof vi.fn> };
|
||||
stdin: { destroy: ReturnType<typeof vi.fn> };
|
||||
stdout: { destroy: ReturnType<typeof vi.fn> };
|
||||
unref: ReturnType<typeof vi.fn>;
|
||||
it.skipIf(process.platform === "win32").each([true, false])(
|
||||
"joins the process group after wrapper exit with exitedBeforeTeardown=%s",
|
||||
async (exitedBeforeTeardown) => {
|
||||
const child = Object.assign(pipedChildFixture(), { pid: 4444 });
|
||||
const queueExit = () => {
|
||||
queueMicrotask(() => {
|
||||
child.exitCode = 0;
|
||||
child.emit("exit", 0, null);
|
||||
});
|
||||
};
|
||||
child.exitCode = null;
|
||||
child.kill = vi.fn(() => true);
|
||||
child.pid = 4444;
|
||||
child.signalCode = null;
|
||||
child.stderr = { destroy: vi.fn() };
|
||||
child.stdin = { destroy: vi.fn() };
|
||||
child.stdout = { destroy: vi.fn() };
|
||||
child.unref = vi.fn();
|
||||
|
||||
let emittedExit = false;
|
||||
let processGroupAlive = true;
|
||||
const processKill = vi.spyOn(process, "kill").mockImplementation((pid, signal) => {
|
||||
expect(pid).toBe(-child.pid);
|
||||
if (!exitedBeforeTeardown && signal === "SIGTERM" && !emittedExit) {
|
||||
emittedExit = true;
|
||||
queueExit();
|
||||
}
|
||||
if (signal === "SIGKILL") {
|
||||
processGroupAlive = false;
|
||||
return true;
|
||||
|
|
@ -133,81 +103,10 @@ describe("gateway benchmark child teardown", () => {
|
|||
killGraceMs: 50,
|
||||
teardownGraceMs: 1,
|
||||
});
|
||||
queueMicrotask(() => {
|
||||
child.exitCode = 0;
|
||||
child.emit("exit", 0, null);
|
||||
});
|
||||
await expect(stopped).resolves.toEqual({
|
||||
exitedBeforeTeardown: true,
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
});
|
||||
expect(processKill).toHaveBeenCalledWith(-child.pid, "SIGTERM");
|
||||
expect(processKill).toHaveBeenCalledWith(-child.pid, "SIGKILL");
|
||||
expect(child.kill).not.toHaveBeenCalled();
|
||||
expect(child.stdin.destroy).not.toHaveBeenCalled();
|
||||
expect(child.stdout.destroy).not.toHaveBeenCalled();
|
||||
expect(child.stderr.destroy).not.toHaveBeenCalled();
|
||||
expect(child.unref).not.toHaveBeenCalled();
|
||||
} finally {
|
||||
processKill.mockRestore();
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === "win32")(
|
||||
"waits for the process group after a teardown-triggered wrapper exit",
|
||||
async () => {
|
||||
const child = new EventEmitter() as EventEmitter & {
|
||||
exitCode: number | null;
|
||||
kill: ReturnType<typeof vi.fn>;
|
||||
pid: number;
|
||||
signalCode: NodeJS.Signals | null;
|
||||
stderr: { destroy: ReturnType<typeof vi.fn> };
|
||||
stdin: { destroy: ReturnType<typeof vi.fn> };
|
||||
stdout: { destroy: ReturnType<typeof vi.fn> };
|
||||
unref: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
child.exitCode = null;
|
||||
child.kill = vi.fn(() => true);
|
||||
child.pid = 4445;
|
||||
child.signalCode = null;
|
||||
child.stderr = { destroy: vi.fn() };
|
||||
child.stdin = { destroy: vi.fn() };
|
||||
child.stdout = { destroy: vi.fn() };
|
||||
child.unref = vi.fn();
|
||||
|
||||
let emittedExit = false;
|
||||
let processGroupAlive = true;
|
||||
const processKill = vi.spyOn(process, "kill").mockImplementation((pid, signal) => {
|
||||
expect(pid).toBe(-child.pid);
|
||||
if (signal === "SIGTERM" && !emittedExit) {
|
||||
emittedExit = true;
|
||||
queueMicrotask(() => {
|
||||
child.exitCode = 0;
|
||||
child.emit("exit", 0, null);
|
||||
});
|
||||
if (exitedBeforeTeardown) {
|
||||
queueExit();
|
||||
}
|
||||
if (signal === "SIGKILL") {
|
||||
processGroupAlive = false;
|
||||
return true;
|
||||
}
|
||||
if (signal === 0 && !processGroupAlive) {
|
||||
throw Object.assign(new Error("gone"), { code: "ESRCH" });
|
||||
}
|
||||
return true;
|
||||
});
|
||||
try {
|
||||
await expect(
|
||||
stopChild(child as unknown as ChildProcess, {
|
||||
killGraceMs: 50,
|
||||
teardownGraceMs: 1,
|
||||
}),
|
||||
).resolves.toEqual({
|
||||
exitedBeforeTeardown: false,
|
||||
exitCode: 0,
|
||||
signal: null,
|
||||
});
|
||||
await expect(stopped).resolves.toEqual({ exitedBeforeTeardown, exitCode: 0, signal: null });
|
||||
expect(processKill).toHaveBeenCalledWith(-child.pid, "SIGTERM");
|
||||
expect(processKill).toHaveBeenCalledWith(-child.pid, "SIGKILL");
|
||||
expect(child.kill).not.toHaveBeenCalled();
|
||||
|
|
|
|||
|
|
@ -252,8 +252,6 @@ async function waitMessage(child: ChildProcess, field: string) {
|
|||
|
||||
it.each([
|
||||
["cpu", false],
|
||||
["heap", false],
|
||||
["cpu", true],
|
||||
["heap", true],
|
||||
] as const)(
|
||||
"maps %s profiles to native workers with preexisting=%s and records retirement",
|
||||
|
|
|
|||
|
|
@ -58,28 +58,6 @@ describe.skipIf(process.platform !== "linux" || typeof process.threadCpuUsage !=
|
|||
expect(existsSync(root)).toBe(false);
|
||||
});
|
||||
|
||||
it("retains preparation failure without starting a child or running work", async () => {
|
||||
const receipt = result();
|
||||
await runResourceGatewayCase({
|
||||
result: receipt,
|
||||
runtime,
|
||||
prepare: async ({ root }) => {
|
||||
roots.push(root);
|
||||
throw new Error("fixture preparation failed");
|
||||
},
|
||||
run: async () => {
|
||||
throw new Error("work must not run");
|
||||
},
|
||||
});
|
||||
expect(receipt).toMatchObject({
|
||||
status: "failed",
|
||||
error: "fixture preparation failed",
|
||||
phases: [],
|
||||
});
|
||||
expect(receipt.shutdown).toBeUndefined();
|
||||
expect(existsSync(roots[0]!)).toBe(true);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ workloadFails: true, exitCode: "0", stopError: "0" },
|
||||
{ workloadFails: false, exitCode: "1", stopError: "0" },
|
||||
|
|
@ -137,11 +115,15 @@ describe.skipIf(process.platform !== "linux" || typeof process.threadCpuUsage !=
|
|||
writeFileSync(archive, "mismatched fixture");
|
||||
await installArchive(archive, "0".repeat(64));
|
||||
},
|
||||
run: async () => {},
|
||||
run: async () => {
|
||||
throw new Error("work must not run");
|
||||
},
|
||||
});
|
||||
expect(receipt).toMatchObject({ status: "failed", fixtures: [], phases: [] });
|
||||
expect(receipt.error).toContain("matching its SHA-256");
|
||||
expect(existsSync(invocations)).toBe(false);
|
||||
expect(receipt.shutdown).toBeUndefined();
|
||||
expect(existsSync(roots[0]!)).toBe(true);
|
||||
});
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -73,7 +73,7 @@ const ASSETS = [
|
|||
"build/src/third_party/issue-descriptions/example.md",
|
||||
];
|
||||
|
||||
function fixture(version: string, hashes: Record<string, string>) {
|
||||
function fixture(version = "1.10.1", hashes = CONTRACT_HASHES["1.10.1"]) {
|
||||
return {
|
||||
declaredVersion: version,
|
||||
manifest: { version, type: "module", bin: { "chrome-devtools-mcp": "./" + CLI } },
|
||||
|
|
@ -87,63 +87,60 @@ describe.each(Object.entries(CONTRACT_HASHES))("patched MCP %s contract", (versi
|
|||
expect(collectPatchedMcpArtifactErrors(fixture(version, hashes))).toEqual([]);
|
||||
});
|
||||
|
||||
it("binds the bundled manifest to the exact declared pin", () => {
|
||||
it("rejects changed bytes for every hashed runtime entry", () => {
|
||||
const input = fixture(version, hashes);
|
||||
input.manifest.version = version === "1.10.1" ? "1.9.0" : "1.10.1";
|
||||
expect(collectPatchedMcpArtifactErrors(input)).toContain(
|
||||
"bundled chrome-devtools-mcp must be ESM version " + version,
|
||||
input.sha256 = () => "0".repeat(64);
|
||||
expect(collectPatchedMcpArtifactErrors(input).toSorted()).toEqual(
|
||||
Object.keys(hashes)
|
||||
.map((file) => "bundled chrome-devtools-mcp has unpatched or changed runtime entry " + file)
|
||||
.toSorted(),
|
||||
);
|
||||
});
|
||||
|
||||
it.each(Object.keys(hashes))("rejects changed or unpatched bytes: %s", (file) => {
|
||||
const input = fixture(version, hashes);
|
||||
input.sha256 = (entry) => (entry === file ? "0".repeat(64) : hashes[entry]);
|
||||
expect(collectPatchedMcpArtifactErrors(input)).toContain(
|
||||
"bundled chrome-devtools-mcp has unpatched or changed runtime entry " + file,
|
||||
);
|
||||
});
|
||||
|
||||
it.each([...Object.keys(hashes), ...ASSETS])("rejects missing runtime entries: %s", (file) => {
|
||||
const input = fixture(version, hashes);
|
||||
input.files.delete(file);
|
||||
expect(collectPatchedMcpArtifactErrors(input)).toContain(
|
||||
file.endsWith("example.md")
|
||||
? "bundled chrome-devtools-mcp is missing third-party issue descriptions"
|
||||
: "bundled chrome-devtools-mcp is missing required runtime entry " + file,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects mixed-version byte contracts", () => {
|
||||
const other = Object.entries(CONTRACT_HASHES).find(([candidate]) => candidate !== version)?.[1];
|
||||
expect(other).toBeDefined();
|
||||
expect(collectPatchedMcpArtifactErrors(fixture(version, other ?? {}))).toContain(
|
||||
"bundled chrome-devtools-mcp has unpatched or changed runtime entry build/src/bin/chrome-devtools-mcp-main.js",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not trust artifact-supplied replacement hashes", () => {
|
||||
const input = fixture(version, hashes);
|
||||
expect(
|
||||
collectPatchedMcpArtifactErrors({
|
||||
...input,
|
||||
manifest: { ...input.manifest, hashes: { [CLI]: "0".repeat(64) } },
|
||||
sha256: (file) => (file === CLI ? "0".repeat(64) : hashes[file]),
|
||||
}),
|
||||
).toContain("bundled chrome-devtools-mcp has unpatched or changed runtime entry " + CLI);
|
||||
});
|
||||
|
||||
it("retains the ESM and CLI requirements", () => {
|
||||
const input = fixture(version, hashes);
|
||||
input.manifest.type = "commonjs";
|
||||
input.manifest.bin["chrome-devtools-mcp"] = "./other.js";
|
||||
expect(collectPatchedMcpArtifactErrors(input)).toEqual([
|
||||
"bundled chrome-devtools-mcp must be ESM version " + version,
|
||||
"bundled chrome-devtools-mcp must expose CLI " + CLI,
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
it.each([null, 1.8, "", "^1.8.0", "~1.9.0", "1.7.0", "1.10.0", "latest", "toString"])(
|
||||
it("binds the bundled manifest to the exact declared pin", () => {
|
||||
const input = fixture();
|
||||
input.manifest.version = "1.9.0";
|
||||
expect(collectPatchedMcpArtifactErrors(input)).toContain(
|
||||
"bundled chrome-devtools-mcp must be ESM version 1.10.1",
|
||||
);
|
||||
});
|
||||
|
||||
it("reports every missing runtime entry", () => {
|
||||
const input = fixture();
|
||||
input.files.clear();
|
||||
expect(collectPatchedMcpArtifactErrors(input).toSorted()).toEqual(
|
||||
[
|
||||
...[...ASSETS.slice(0, -1), ...Object.keys(CONTRACT_HASHES["1.10.1"])].map(
|
||||
(file) => "bundled chrome-devtools-mcp is missing required runtime entry " + file,
|
||||
),
|
||||
"bundled chrome-devtools-mcp is missing third-party issue descriptions",
|
||||
].toSorted(),
|
||||
);
|
||||
});
|
||||
|
||||
it("does not trust artifact-supplied replacement hashes", () => {
|
||||
const input = fixture();
|
||||
expect(
|
||||
collectPatchedMcpArtifactErrors({
|
||||
...input,
|
||||
manifest: { ...input.manifest, hashes: { [CLI]: "0".repeat(64) } },
|
||||
sha256: (file) => (file === CLI ? "0".repeat(64) : input.sha256(file)),
|
||||
}),
|
||||
).toContain("bundled chrome-devtools-mcp has unpatched or changed runtime entry " + CLI);
|
||||
});
|
||||
|
||||
it("retains the ESM and CLI requirements", () => {
|
||||
const input = fixture();
|
||||
input.manifest.type = "commonjs";
|
||||
input.manifest.bin["chrome-devtools-mcp"] = "./other.js";
|
||||
expect(collectPatchedMcpArtifactErrors(input)).toEqual([
|
||||
"bundled chrome-devtools-mcp must be ESM version 1.10.1",
|
||||
"bundled chrome-devtools-mcp must expose CLI " + CLI,
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([null, "^1.8.0", "1.10.0", "toString"])(
|
||||
"rejects unpinned or unknown declarations: %s",
|
||||
(declaredVersion) => {
|
||||
expect(
|
||||
|
|
|
|||
|
|
@ -228,30 +228,9 @@ async function packedPluginFixture(runtime = true) {
|
|||
}
|
||||
|
||||
describe("prepared plugin npm publication", () => {
|
||||
it("seals the full selection, including previously published packages", () => {
|
||||
const manifest = createPreparedNpmRelease(preparation());
|
||||
expect(manifest.packages.map((entry: { packageName: string }) => entry.packageName)).toEqual([
|
||||
"@openclaw/demo",
|
||||
"@openclaw/existing",
|
||||
]);
|
||||
expect(manifest.packages[1].artifact).toMatchObject({
|
||||
runId: 101,
|
||||
runAttempt: 2,
|
||||
artifactId: 2,
|
||||
});
|
||||
});
|
||||
|
||||
it.each([
|
||||
["beta bootstrap", version, "beta", "beta", "default", "npm-token-bootstrap"],
|
||||
["regular stable bootstrap", "2026.9.32", "stable", "latest", "default", "npm-token-bootstrap"],
|
||||
[
|
||||
"regular stable correction bootstrap",
|
||||
"2026.9.32-1",
|
||||
"stable",
|
||||
"latest",
|
||||
"default",
|
||||
"npm-token-bootstrap",
|
||||
],
|
||||
[
|
||||
"extended-stable OIDC",
|
||||
"2026.9.33",
|
||||
|
|
@ -280,24 +259,16 @@ describe("prepared plugin npm publication", () => {
|
|||
},
|
||||
);
|
||||
|
||||
it.each(["npm-oidc", "npm-token-bootstrap"])(
|
||||
"rejects retired alpha preparation on %s",
|
||||
(route) => {
|
||||
expect(() =>
|
||||
createPreparedNpmRelease(
|
||||
preparation(
|
||||
{ version: "2026.9.3-alpha.1", channel: "alpha", publishTag: "alpha" },
|
||||
route,
|
||||
),
|
||||
),
|
||||
).toThrow("Alpha releases are retired");
|
||||
},
|
||||
);
|
||||
it("rejects retired alpha preparation", () => {
|
||||
expect(() =>
|
||||
createPreparedNpmRelease(
|
||||
preparation({ version: "2026.9.3-alpha.1", channel: "alpha", publishTag: "alpha" }),
|
||||
),
|
||||
).toThrow("Alpha releases are retired");
|
||||
});
|
||||
|
||||
it.each([
|
||||
["first extended-stable patch on latest", "2026.9.33", "stable", "latest", "default"],
|
||||
["later extended-stable patch on latest", "2026.9.34", "stable", "latest", "default"],
|
||||
["extended-stable correction on latest", "2026.9.33-1", "stable", "latest", "default"],
|
||||
["explicit extended-stable", "2026.9.33", "stable", "extended-stable", "extended-stable"],
|
||||
])(
|
||||
"rejects bootstrap preparation for %s",
|
||||
|
|
@ -521,51 +492,10 @@ describe("prepared npm registry readback", () => {
|
|||
return { bytes, packument, params };
|
||||
}
|
||||
|
||||
it("adopts an already-published version only after exact byte and selector readback", async () => {
|
||||
const { bytes, packument, params } = registryFixture();
|
||||
const requests: string[] = [];
|
||||
const result = await verifyPreparedNpmRegistry({
|
||||
...params,
|
||||
fetchImpl: async (input: string) => {
|
||||
requests.push(input);
|
||||
return input.endsWith(".tgz")
|
||||
? new Response(new Uint8Array(bytes))
|
||||
: Response.json(packument);
|
||||
},
|
||||
});
|
||||
expect(result).toEqual({ alreadyPublished: true });
|
||||
expect(requests).toHaveLength(2);
|
||||
});
|
||||
|
||||
async function publishedFixture(beta: string) {
|
||||
it("refuses an incomparable selector on a version this run did not publish", async () => {
|
||||
const fixture = registryFixture(readFileSync((await packedPluginFixture()).tarballPath));
|
||||
fixture.packument["dist-tags"].beta = beta;
|
||||
return fixture;
|
||||
}
|
||||
|
||||
it("passes a version this run did not publish once a later release owns its selector", async () => {
|
||||
const { bytes, packument, params } = await publishedFixture("2026.9.2-beta.2");
|
||||
let tarballReads = 0;
|
||||
await expect(
|
||||
verifyPublishedNpmRegistry({
|
||||
...params,
|
||||
fetchImpl: async (input: string) => {
|
||||
if (input.endsWith(".tgz")) {
|
||||
tarballReads += 1;
|
||||
return new Response(new Uint8Array(bytes));
|
||||
}
|
||||
return Response.json(packument);
|
||||
},
|
||||
}),
|
||||
).resolves.toEqual({ alreadyPublished: true, supersededBy: "2026.9.2-beta.2" });
|
||||
expect(tarballReads).toBe(1);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["lagging", "2026.9.1-beta.1"],
|
||||
["incomparable", "not-a-version"],
|
||||
])("still refuses a %s selector on a version this run did not publish", async (_label, beta) => {
|
||||
const { bytes, packument, params } = await publishedFixture(beta);
|
||||
const { bytes, packument, params } = fixture;
|
||||
packument["dist-tags"].beta = "not-a-version";
|
||||
await expect(
|
||||
verifyPublishedNpmRegistry({
|
||||
...params,
|
||||
|
|
@ -661,7 +591,7 @@ describe("prepared npm registry readback", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it.each(["0", "-1", "NaN", "1.5"])("rejects invalid readback timeout %s", async (timeout) => {
|
||||
it.each(["0", "NaN", "1.5"])("rejects invalid readback timeout %s", async (timeout) => {
|
||||
vi.stubEnv("OPENCLAW_NPM_READBACK_TIMEOUT_MS", timeout);
|
||||
const { params, packument } = registryFixture();
|
||||
await expect(
|
||||
|
|
|
|||
|
|
@ -171,43 +171,18 @@ describe("scripts/plugin-release-pretag-pack-check.ts process-tree proof", () =>
|
|||
expect(isProcessAlive(directPid)).toBe(true);
|
||||
expect(isProcessAlive(descendantPid)).toBe(true);
|
||||
const readyAt = Date.now();
|
||||
let thrown: unknown;
|
||||
try {
|
||||
await withinTest(releaseAndWait(), signal);
|
||||
} catch (error) {
|
||||
thrown = error;
|
||||
}
|
||||
const elapsedMs = Date.now() - startedAt;
|
||||
const completionMs = Date.now() - readyAt;
|
||||
|
||||
expect(thrown).toMatchObject({
|
||||
await expect(withinTest(releaseAndWait(), signal)).rejects.toMatchObject({
|
||||
code: "ETIMEDOUT",
|
||||
message:
|
||||
"plugin runtime build for @openclaw/demo-plugin timed out after 100ms: node --import tsx scripts/check-plugin-npm-runtime-builds.mts --package extensions/demo-plugin",
|
||||
});
|
||||
const elapsedMs = Date.now() - startedAt;
|
||||
const completionMs = Date.now() - readyAt;
|
||||
expect(elapsedMs).toBeGreaterThanOrEqual(timeoutMs * 0.75);
|
||||
expect(completionMs).toBeLessThan(7_500);
|
||||
expect(Number.isInteger(directPid) && directPid > 1).toBe(true);
|
||||
expect(Number.isInteger(descendantPid) && descendantPid > 1).toBe(true);
|
||||
// requireProcessTreeExit joins the exact tree before this command rejects.
|
||||
expect(isProcessAlive(directPid)).toBe(false);
|
||||
expect(isProcessAlive(descendantPid)).toBe(false);
|
||||
|
||||
const proof = {
|
||||
timeoutCode: (thrown as { code?: string }).code,
|
||||
elapsedMs,
|
||||
completionMs,
|
||||
completionBounded: completionMs < 7_500,
|
||||
directExited: !isProcessAlive(directPid),
|
||||
descendantExited: !isProcessAlive(descendantPid),
|
||||
};
|
||||
console.log(`pretag-caller-process-tree-proof ${JSON.stringify(proof)}`);
|
||||
expect(proof).toMatchObject({
|
||||
timeoutCode: "ETIMEDOUT",
|
||||
completionBounded: true,
|
||||
directExited: true,
|
||||
descendantExited: true,
|
||||
});
|
||||
} finally {
|
||||
await releaseAndWait().catch(() => {});
|
||||
directPid ||= readPid(directPidFile);
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
import { readFileSync, statSync } from "node:fs";
|
||||
import { dirname, relative, resolve } from "node:path";
|
||||
import * as ts from "typescript/unstable/ast";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { expect, it } from "vitest";
|
||||
import { createNativeTypeScriptParser } from "../../scripts/lib/native-typescript.mts";
|
||||
import { collectRuntimeImportClosure } from "../../scripts/lib/runtime-import-closure.mts";
|
||||
|
||||
const components = [
|
||||
"scripts/pr",
|
||||
|
|
@ -17,85 +15,14 @@ it.each([
|
|||
])(
|
||||
"retains %s and its relative ESM runtime dependencies in the wrapper inventory",
|
||||
(entrypoint) => {
|
||||
const root = process.cwd();
|
||||
const pending = [entrypoint];
|
||||
const visited = new Set<string>();
|
||||
const missing = new Set<string>();
|
||||
const parser = createNativeTypeScriptParser({ cwd: root });
|
||||
try {
|
||||
while (pending.length > 0) {
|
||||
const file = pending.pop();
|
||||
if (!file || visited.has(file)) {
|
||||
continue;
|
||||
}
|
||||
visited.add(file);
|
||||
if (
|
||||
!components.some((component) => file === component || file.startsWith(`${component}/`))
|
||||
) {
|
||||
missing.add(file);
|
||||
}
|
||||
const absolute = resolve(root, file);
|
||||
const source = parser.parseSourceFile(file, readFileSync(absolute, "utf8"));
|
||||
const enqueue = (specifier: string) => {
|
||||
if (!specifier.startsWith(".")) {
|
||||
return;
|
||||
}
|
||||
const literal = resolve(dirname(absolute), specifier);
|
||||
// Explicit runtime files win over adjacent declarations selected by TypeScript.
|
||||
const target = [literal, literal.replace(/\.([cm]?)js$/, ".$1ts")].find((candidate) =>
|
||||
statSync(candidate, { throwIfNoEntry: false })?.isFile(),
|
||||
);
|
||||
if (!target || /\.d\.[cm]?ts$/.test(target)) {
|
||||
throw new Error(`Cannot resolve wrapper runtime dependency ${file}: ${specifier}`);
|
||||
}
|
||||
pending.push(relative(root, target).replaceAll("\\", "/"));
|
||||
};
|
||||
const visit = (node: ts.Node): void => {
|
||||
if (ts.isImportDeclaration(node) && ts.isStringLiteral(node.moduleSpecifier)) {
|
||||
const clause = node.importClause;
|
||||
const bindings = clause?.namedBindings;
|
||||
if (
|
||||
!clause ||
|
||||
(clause.phaseModifier !== ts.SyntaxKind.TypeKeyword &&
|
||||
(clause.name ||
|
||||
!bindings ||
|
||||
ts.isNamespaceImport(bindings) ||
|
||||
bindings.elements.some((element) => !element.isTypeOnly)))
|
||||
) {
|
||||
enqueue(node.moduleSpecifier.text);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (ts.isExportDeclaration(node)) {
|
||||
const clause = node.exportClause;
|
||||
if (
|
||||
node.moduleSpecifier &&
|
||||
ts.isStringLiteral(node.moduleSpecifier) &&
|
||||
!node.isTypeOnly &&
|
||||
(!clause ||
|
||||
ts.isNamespaceExport(clause) ||
|
||||
clause.elements.some((element) => !element.isTypeOnly))
|
||||
) {
|
||||
enqueue(node.moduleSpecifier.text);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (ts.isImportTypeNode(node)) {
|
||||
return;
|
||||
}
|
||||
if (ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword) {
|
||||
const argument = node.arguments[0];
|
||||
if (argument && ts.isStringLiteralLikeNode(argument)) {
|
||||
enqueue(argument.text);
|
||||
}
|
||||
}
|
||||
node.forEachChild(visit);
|
||||
};
|
||||
visit(source);
|
||||
}
|
||||
} finally {
|
||||
parser.close();
|
||||
}
|
||||
expect([...missing].toSorted()).toEqual([]);
|
||||
const closure = collectRuntimeImportClosure(process.cwd(), [entrypoint], {
|
||||
includeDynamicImports: true,
|
||||
});
|
||||
expect(
|
||||
closure.filter(
|
||||
(file) =>
|
||||
!components.some((component) => file === component || file.startsWith(`${component}/`)),
|
||||
),
|
||||
).toEqual([]);
|
||||
},
|
||||
);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import { execFileSync, spawnSync } from "node:child_process";
|
||||
import { execFileSync, spawnSync, type SpawnSyncReturns } from "node:child_process";
|
||||
import {
|
||||
copyFileSync,
|
||||
cpSync,
|
||||
|
|
@ -68,6 +68,27 @@ function commit(root: string, excluded: string[] = []) {
|
|||
return { root, sha: git("rev-parse", "HEAD"), git };
|
||||
}
|
||||
|
||||
function removeBlob(source: ReturnType<typeof commit>, path: string) {
|
||||
const oid = source.git("rev-parse", `${source.sha}:${path}`);
|
||||
rmSync(join(source.root, ".git/objects", oid.slice(0, 2), oid.slice(2)));
|
||||
return oid;
|
||||
}
|
||||
|
||||
function configureUnavailablePromisor(source: ReturnType<typeof commit>) {
|
||||
source.git("config", "remote.origin.url", "fixture::unavailable");
|
||||
source.git("config", "remote.origin.promisor", "true");
|
||||
source.git("config", "extensions.partialClone", "origin");
|
||||
source.git("config", "protocol.fixture.allow", "always");
|
||||
}
|
||||
|
||||
function expectRejected(result: SpawnSyncReturns<string>, error?: string) {
|
||||
expect(result.status, result.stderr).toBe(1);
|
||||
if (error) {
|
||||
expect(result.stderr).toContain(error);
|
||||
}
|
||||
expect(result.stdout).toBe("");
|
||||
}
|
||||
|
||||
function fixture(
|
||||
files: Record<string, string> = {},
|
||||
parser = false,
|
||||
|
|
@ -226,27 +247,24 @@ describe("frozen admission Docker consumer aliases", () => {
|
|||
'import { writeFileSync } from "node:fs";',
|
||||
'writeFileSync(`${process.env.HOME}/selected-code-executed`, "executed");',
|
||||
].join("\n");
|
||||
const consumerAliases = aliases.filter(({ lane }) =>
|
||||
["live-gateway", "mcp-channels"].includes(lane),
|
||||
);
|
||||
|
||||
it.each(aliases)(
|
||||
it.each(consumerAliases)(
|
||||
"rejects a missing committed $lane contract before emitting admission",
|
||||
({ lane, path, current }) => {
|
||||
const source = `${executionSentinel}\n${current}\n`;
|
||||
const f = fixture({ [path]: source });
|
||||
const tree = f.selected.git("rev-parse", "HEAD^{tree}");
|
||||
const oid = f.selected.git("rev-parse", `${f.selected.sha}:${path}`);
|
||||
f.selected.git("config", "remote.origin.url", "fixture::unavailable");
|
||||
f.selected.git("config", "remote.origin.promisor", "true");
|
||||
f.selected.git("config", "extensions.partialClone", "origin");
|
||||
f.selected.git("config", "protocol.fixture.allow", "always");
|
||||
rmSync(join(f.selected.root, ".git/objects", oid.slice(0, 2), oid.slice(2)));
|
||||
configureUnavailablePromisor(f.selected);
|
||||
removeBlob(f.selected, path);
|
||||
expect(f.selected.git("rev-parse", "HEAD^{tree}")).toBe(tree);
|
||||
expect(readFileSync(join(f.selected.root, path), "utf8")).toBe(source);
|
||||
|
||||
const result = f.run({ docker: { lanes: [lane] } });
|
||||
expect(existsSync(join(f.root, "selected-code-executed"))).toBe(false);
|
||||
expect(result.status, result.stderr).toBe(1);
|
||||
expect(result.stderr).toContain("unable to read selected source");
|
||||
expect(result.stdout).toBe("");
|
||||
expectRejected(result, "unable to read selected source");
|
||||
|
||||
const currentOnly = f.run(
|
||||
{ docker: { lanes: [lane] } },
|
||||
|
|
@ -258,11 +276,10 @@ describe("frozen admission Docker consumer aliases", () => {
|
|||
},
|
||||
);
|
||||
|
||||
it.each(
|
||||
aliases.flatMap((alias) =>
|
||||
(["current", "legacy"] as const).map((dialect) => Object.assign({}, alias, { dialect })),
|
||||
),
|
||||
)("admits $lane with the committed $dialect contract", (alias) => {
|
||||
it.each([
|
||||
...aliases.map((alias) => Object.assign({}, alias, { dialect: "current" as const })),
|
||||
...consumerAliases.map((alias) => Object.assign({}, alias, { dialect: "legacy" as const })),
|
||||
])("admits $lane with the committed $dialect contract", (alias) => {
|
||||
const { lane, consumer, path, mode, dialect } = alias;
|
||||
const f = fixture({ [path]: `${executionSentinel}\n${alias[dialect]}\n` });
|
||||
const result = f.run({ docker: { lanes: [lane] } });
|
||||
|
|
@ -290,7 +307,7 @@ describe("frozen admission Docker consumer aliases", () => {
|
|||
expect(existsSync(join(f.tooling.root, "node_modules"))).toBe(false);
|
||||
});
|
||||
|
||||
it.each(aliases)(
|
||||
it.each(consumerAliases)(
|
||||
"preserves the legitimate absent-file fallback for $lane",
|
||||
({ lane, consumer, mode }) => {
|
||||
const f = fixture();
|
||||
|
|
@ -350,8 +367,6 @@ describe("frozen admission Docker consumer aliases", () => {
|
|||
it.each([
|
||||
{ lane: "live-gateway", removed: [pluginAssertions], consumer: "live-cli-backend" },
|
||||
{ lane: "mcp-channels", removed: [cliMetadata], consumer: "plugins" },
|
||||
{ lane: "kitchen-sink-rpc", removed: [cliMetadata], consumer: "plugins" },
|
||||
{ lane: "plugins-offline", removed: [cliMetadata], consumer: "plugins" },
|
||||
{ lane: "docker-package-install", removed: [cliMetadata, pluginAssertions], consumer: null },
|
||||
])("keeps unreadable unrelated contracts inert for $lane", ({ lane, removed, consumer }) => {
|
||||
const f = fixture({
|
||||
|
|
@ -359,8 +374,7 @@ describe("frozen admission Docker consumer aliases", () => {
|
|||
[pluginAssertions]: `${executionSentinel}\nexport function assertPluginUninstallConfigState() {}\n`,
|
||||
});
|
||||
for (const path of removed) {
|
||||
const oid = f.selected.git("rev-parse", `${f.selected.sha}:${path}`);
|
||||
rmSync(join(f.selected.root, ".git/objects", oid.slice(0, 2), oid.slice(2)));
|
||||
removeBlob(f.selected, path);
|
||||
}
|
||||
const result = f.run({ docker: { lanes: [lane] } });
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
|
|
@ -380,32 +394,29 @@ describe("frozen admission upgrade Docker aliases", () => {
|
|||
const companion = "scripts/e2e/lib/plugin-index-sqlite.mjs";
|
||||
const pluginAssertions = "scripts/e2e/lib/plugins/assertions.mjs";
|
||||
|
||||
it.each(
|
||||
lanes.flatMap((lane) =>
|
||||
[
|
||||
{
|
||||
shape: "malformed version",
|
||||
version: "invalid",
|
||||
error: "selected upgrade target has an invalid release version",
|
||||
},
|
||||
{
|
||||
shape: "unsupported correction",
|
||||
version: "2026.7.33-1",
|
||||
error: "unsupported extended-stable correction",
|
||||
},
|
||||
{
|
||||
shape: "missing scenario",
|
||||
version: "2026.7.33",
|
||||
error: "selected extended-stable target lacks its scenario",
|
||||
},
|
||||
{
|
||||
shape: "missing companion blob",
|
||||
version: "2026.7.33",
|
||||
error: "unable to read selected source",
|
||||
},
|
||||
].map((value) => Object.assign({}, value, { lane })),
|
||||
),
|
||||
)("rejects $lane with $shape before emitting admission", ({ lane, shape, version, error }) => {
|
||||
it.each([
|
||||
{
|
||||
shape: "malformed version",
|
||||
version: "invalid",
|
||||
error: "selected upgrade target has an invalid release version",
|
||||
},
|
||||
{
|
||||
shape: "unsupported correction",
|
||||
version: "2026.7.33-1",
|
||||
error: "unsupported extended-stable correction",
|
||||
},
|
||||
{
|
||||
shape: "missing scenario",
|
||||
version: "2026.7.33",
|
||||
error: "selected extended-stable target lacks its scenario",
|
||||
},
|
||||
{
|
||||
shape: "missing companion blob",
|
||||
version: "2026.7.33",
|
||||
error: "unable to read selected source",
|
||||
},
|
||||
])("rejects an upgrade alias with $shape before admission", ({ shape, version, error }) => {
|
||||
const lane = "root-managed-vps-upgrade";
|
||||
const files =
|
||||
shape === "missing companion blob"
|
||||
? survivorFiles(version)
|
||||
|
|
@ -417,18 +428,13 @@ describe("frozen admission upgrade Docker aliases", () => {
|
|||
for (const path of Object.keys(files).filter((file) => file !== companion)) {
|
||||
expect(f.selected.git("rev-parse", `${f.selected.sha}:${path}`), path).not.toBe(oid);
|
||||
}
|
||||
f.selected.git("config", "remote.origin.url", "fixture::unavailable");
|
||||
f.selected.git("config", "remote.origin.promisor", "true");
|
||||
f.selected.git("config", "extensions.partialClone", "origin");
|
||||
f.selected.git("config", "protocol.fixture.allow", "always");
|
||||
rmSync(join(f.selected.root, ".git/objects", oid.slice(0, 2), oid.slice(2)));
|
||||
configureUnavailablePromisor(f.selected);
|
||||
removeBlob(f.selected, companion);
|
||||
expect(f.selected.git("rev-parse", "HEAD^{tree}")).toBe(tree);
|
||||
expect(readFileSync(join(f.selected.root, companion), "utf8")).toBe(files[companion]);
|
||||
}
|
||||
const result = f.run({ docker: { lanes: [lane] } });
|
||||
expect(result.status, result.stderr).toBe(1);
|
||||
expect(result.stderr).toContain(error);
|
||||
expect(result.stdout).toBe("");
|
||||
expectRejected(result, error);
|
||||
for (const root of [f.root, f.selected.root, f.tooling.root]) {
|
||||
expect(existsSync(join(root, "selected-code-executed"))).toBe(false);
|
||||
}
|
||||
|
|
@ -440,15 +446,14 @@ describe("frozen admission upgrade Docker aliases", () => {
|
|||
expect(JSON.parse(currentOnly.stdout).sources.selected).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(
|
||||
lanes.flatMap((lane) =>
|
||||
[
|
||||
{ version: "2026.6.35", recipe: "config-recipe.mjs", train: "extended-stable" },
|
||||
{ version: "2026.7.33", recipe: "config-recipe.mts", train: "extended-stable" },
|
||||
{ version: "2026.9.9", recipe: "", train: "stable" },
|
||||
].map((value) => Object.assign({}, value, { lane })),
|
||||
),
|
||||
)("admits $lane with committed $version contracts", ({ lane, version, recipe, train }) => {
|
||||
it.each([
|
||||
...[
|
||||
{ version: "2026.6.35", recipe: "config-recipe.mjs", train: "extended-stable" },
|
||||
{ version: "2026.7.33", recipe: "config-recipe.mts", train: "extended-stable" },
|
||||
{ version: "2026.9.9", recipe: "", train: "stable" },
|
||||
].map((value) => Object.assign({}, value, { lane: "root-managed-vps-upgrade" })),
|
||||
{ lane: "update-restart-auth", version: "2026.9.9", recipe: "", train: "stable" },
|
||||
])("admits $lane with committed $version contracts", ({ lane, version, recipe, train }) => {
|
||||
const files = recipe
|
||||
? survivorFiles(version, recipe)
|
||||
: { "package.json": JSON.stringify({ type: "module", version }) };
|
||||
|
|
@ -456,8 +461,7 @@ describe("frozen admission upgrade Docker aliases", () => {
|
|||
...files,
|
||||
[pluginAssertions]: "throw new Error('unselected plugin code executed');",
|
||||
});
|
||||
const oid = f.selected.git("rev-parse", `${f.selected.sha}:${pluginAssertions}`);
|
||||
rmSync(join(f.selected.root, ".git/objects", oid.slice(0, 2), oid.slice(2)));
|
||||
removeBlob(f.selected, pluginAssertions);
|
||||
const result = f.run({ docker: { lanes: [lane] } });
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
const record = JSON.parse(result.stdout);
|
||||
|
|
@ -498,43 +502,38 @@ describe("frozen admission upgrade Docker aliases", () => {
|
|||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"plugins-offline",
|
||||
"docker-package-install",
|
||||
"live-cli-backend-claude",
|
||||
"live-cli-backend-gemini",
|
||||
"update-first-hop-compat",
|
||||
"release-user-journey",
|
||||
"release-upgrade-user-journey",
|
||||
])("keeps unselected upgrade contracts inert for %s", (lane) => {
|
||||
const files: Record<string, string> = {
|
||||
"package.json": '{"type":"module","version":"invalid"}',
|
||||
"src/infra/clawhub-install-trust.ts": "throw new Error('unselected upgrade code executed');",
|
||||
"scripts/print-cli-backend-live-metadata.ts":
|
||||
"throw new Error('unselected CLI code executed');",
|
||||
};
|
||||
if (lane === "update-first-hop-compat") {
|
||||
files["scripts/runtime-postbuild.mts"] =
|
||||
`throw new Error("selected postbuild executed");\n${readFileSync("scripts/runtime-postbuild.mts", "utf8")}`;
|
||||
}
|
||||
const f = fixture(files);
|
||||
for (const path of [
|
||||
"src/infra/clawhub-install-trust.ts",
|
||||
"scripts/print-cli-backend-live-metadata.ts",
|
||||
]) {
|
||||
const oid = f.selected.git("rev-parse", `${f.selected.sha}:${path}`);
|
||||
rmSync(join(f.selected.root, ".git/objects", oid.slice(0, 2), oid.slice(2)));
|
||||
}
|
||||
const requestedLanes = expandUpdateFirstHopCompatLanes([lane]);
|
||||
const result = f.run({ docker: { lanes: requestedLanes } });
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
const record = JSON.parse(result.stdout);
|
||||
expect(record.docker).toEqual({ lanes: requestedLanes, omitted: [], status: "ADMITTED" });
|
||||
expect(record.selection.consumers).toEqual(lane === "plugins-offline" ? ["plugins"] : []);
|
||||
expect(record.contracts.map((contract: { consumer: string }) => contract.consumer)).toEqual(
|
||||
record.selection.consumers,
|
||||
);
|
||||
});
|
||||
it.each(["plugins-offline", "docker-package-install", "update-first-hop-compat"])(
|
||||
"keeps unselected upgrade contracts inert for %s",
|
||||
(lane) => {
|
||||
const files: Record<string, string> = {
|
||||
"package.json": '{"type":"module","version":"invalid"}',
|
||||
"src/infra/clawhub-install-trust.ts":
|
||||
"throw new Error('unselected upgrade code executed');",
|
||||
"scripts/print-cli-backend-live-metadata.ts":
|
||||
"throw new Error('unselected CLI code executed');",
|
||||
};
|
||||
if (lane === "update-first-hop-compat") {
|
||||
files["scripts/runtime-postbuild.mts"] =
|
||||
`throw new Error("selected postbuild executed");\n${readFileSync("scripts/runtime-postbuild.mts", "utf8")}`;
|
||||
}
|
||||
const f = fixture(files);
|
||||
for (const path of [
|
||||
"src/infra/clawhub-install-trust.ts",
|
||||
"scripts/print-cli-backend-live-metadata.ts",
|
||||
]) {
|
||||
removeBlob(f.selected, path);
|
||||
}
|
||||
const requestedLanes = expandUpdateFirstHopCompatLanes([lane]);
|
||||
const result = f.run({ docker: { lanes: requestedLanes } });
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
const record = JSON.parse(result.stdout);
|
||||
expect(record.docker).toEqual({ lanes: requestedLanes, omitted: [], status: "ADMITTED" });
|
||||
expect(record.selection.consumers).toEqual(lane === "plugins-offline" ? ["plugins"] : []);
|
||||
expect(record.contracts.map((contract: { consumer: string }) => contract.consumer)).toEqual(
|
||||
record.selection.consumers,
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
describe("frozen admission bootstrap repairs", () => {
|
||||
|
|
@ -542,49 +541,35 @@ describe("frozen admission bootstrap repairs", () => {
|
|||
const reader = "scripts/lib/frozen-target-source.mjs";
|
||||
const shell = "scripts/lib/frozen-target-compat.sh";
|
||||
|
||||
it.each([
|
||||
reader,
|
||||
"scripts/lib/docker-e2e-scenarios.mts",
|
||||
"scripts/lib/record-shared.mjs",
|
||||
shell,
|
||||
"scripts/lib/trusted-native-typescript.mjs",
|
||||
"scripts/lib/native-typescript.mts",
|
||||
])("rejects dirty executable %s before any dependent code runs at unchanged HEAD", (path) => {
|
||||
const f = fixture({ "src/config/zod-schema.ts": "lastRunAt:" });
|
||||
const sentinel = join(f.root, "dependent-code-executed");
|
||||
const file = join(f.tooling.root, path);
|
||||
const payload =
|
||||
path === shell
|
||||
? `\nprintf executed > '${sentinel}'\n`
|
||||
: `\n(await import("node:fs")).writeFileSync(${JSON.stringify(sentinel)}, "executed");\n`;
|
||||
writeFileSync(file, readFileSync(file, "utf8") + payload);
|
||||
expect(f.tooling.git("rev-parse", "HEAD")).toBe(f.tooling.sha);
|
||||
const result = f.run({ consumers: ["onboard"] });
|
||||
expect(existsSync(sentinel), result.stderr).toBe(false);
|
||||
expect(result.status, result.stderr).toBe(1);
|
||||
expect(result.stderr).toContain(`tooling closure does not match committed source: ${path}`);
|
||||
expect(result.stdout).toBe("");
|
||||
});
|
||||
it.each([reader, "scripts/lib/docker-e2e-scenarios.mts", shell])(
|
||||
"rejects dirty executable %s before any dependent code runs at unchanged HEAD",
|
||||
(path) => {
|
||||
const f = fixture({ "src/config/zod-schema.ts": "lastRunAt:" });
|
||||
const sentinel = join(f.root, "dependent-code-executed");
|
||||
const file = join(f.tooling.root, path);
|
||||
const payload =
|
||||
path === shell
|
||||
? `\nprintf executed > '${sentinel}'\n`
|
||||
: `\n(await import("node:fs")).writeFileSync(${JSON.stringify(sentinel)}, "executed");\n`;
|
||||
writeFileSync(file, readFileSync(file, "utf8") + payload);
|
||||
expect(f.tooling.git("rev-parse", "HEAD")).toBe(f.tooling.sha);
|
||||
const result = f.run({ consumers: ["onboard"] });
|
||||
expect(existsSync(sentinel), result.stderr).toBe(false);
|
||||
expectRejected(result, `tooling closure does not match committed source: ${path}`);
|
||||
},
|
||||
);
|
||||
|
||||
it.each([
|
||||
entrypoint,
|
||||
"scripts/lib/official-external-channel-catalog.json",
|
||||
"scripts/lib/official-external-provider-catalog.json",
|
||||
"scripts/lib/record-shared.mjs",
|
||||
"scripts/lib/upgrade-survivor-scenarios.json",
|
||||
`${recipeDirectory}/agents.json`,
|
||||
"package.json",
|
||||
"pnpm-lock.yaml",
|
||||
])("rejects dirty closure data %s at unchanged HEAD", (path) => {
|
||||
const f = fixture();
|
||||
const file = join(f.tooling.root, path);
|
||||
writeFileSync(file, readFileSync(file, "utf8") + "\n");
|
||||
expect(f.tooling.git("rev-parse", "HEAD")).toBe(f.tooling.sha);
|
||||
const result = f.run({});
|
||||
expect(result.status, result.stderr).toBe(1);
|
||||
expect(result.stderr).toContain(`tooling closure does not match committed source: ${path}`);
|
||||
expect(result.stdout).toBe("");
|
||||
});
|
||||
it.each([entrypoint, `${recipeDirectory}/agents.json`])(
|
||||
"rejects dirty closure data %s at unchanged HEAD",
|
||||
(path) => {
|
||||
const f = fixture();
|
||||
const file = join(f.tooling.root, path);
|
||||
writeFileSync(file, readFileSync(file, "utf8") + "\n");
|
||||
expect(f.tooling.git("rev-parse", "HEAD")).toBe(f.tooling.sha);
|
||||
const result = f.run({});
|
||||
expectRejected(result, `tooling closure does not match committed source: ${path}`);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["file", "parent directory"] as const)(
|
||||
"rejects a tooling %s symlink even when its bytes match",
|
||||
|
|
@ -597,9 +582,7 @@ describe("frozen admission bootstrap repairs", () => {
|
|||
rmSync(original, { recursive: true });
|
||||
symlinkSync(outside, original);
|
||||
const result = f.run({});
|
||||
expect(result.status, result.stderr).toBe(1);
|
||||
expect(result.stderr).toContain("tooling closure requires an owned regular file:");
|
||||
expect(result.stdout).toBe("");
|
||||
expectRejected(result, "tooling closure requires an owned regular file:");
|
||||
},
|
||||
);
|
||||
|
||||
|
|
@ -635,22 +618,18 @@ describe("frozen admission bootstrap repairs", () => {
|
|||
const f = fixture();
|
||||
f.tooling.git("commit", "--allow-empty", "-qm", "different HEAD");
|
||||
const result = f.run({});
|
||||
expect(result.status, result.stderr).toBe(1);
|
||||
expect(result.stderr).toContain("checkout does not match OPENCLAW_SELECTED_SHA");
|
||||
expect(result.stdout).toBe("");
|
||||
expectRejected(result, "checkout does not match OPENCLAW_SELECTED_SHA");
|
||||
});
|
||||
|
||||
it.each([entrypoint, reader, `${recipeDirectory}/agents.json`])(
|
||||
it.each([entrypoint, `${recipeDirectory}/agents.json`])(
|
||||
"rejects a missing committed tooling object %s without hydration",
|
||||
(path) => {
|
||||
const f = fixture();
|
||||
const oid = f.tooling.git("rev-parse", `${f.tooling.sha}:${path}`);
|
||||
f.tooling.git("config", "remote.origin.url", "fixture::unavailable");
|
||||
f.tooling.git("config", "remote.origin.promisor", "true");
|
||||
rmSync(join(f.tooling.root, ".git/objects", oid.slice(0, 2), oid.slice(2)));
|
||||
removeBlob(f.tooling, path);
|
||||
const result = f.run({});
|
||||
expect(result.status, result.stderr).toBe(1);
|
||||
expect(result.stdout).toBe("");
|
||||
expectRejected(result);
|
||||
},
|
||||
);
|
||||
|
||||
|
|
@ -752,12 +731,6 @@ describe("frozen admission entry", () => {
|
|||
allow: true,
|
||||
mode: "unsupported",
|
||||
},
|
||||
{
|
||||
name: "current authorized",
|
||||
files: { "src/config/zod-schema.session-config.ts": "coldStorage: z.object({})" },
|
||||
allow: true,
|
||||
mode: "required",
|
||||
},
|
||||
{
|
||||
name: "current declaration regression",
|
||||
files: {
|
||||
|
|
@ -857,9 +830,7 @@ describe("frozen admission entry", () => {
|
|||
expect(existsSync(join(f.tooling.root, "node_modules"))).toBe(false);
|
||||
expect(f.run(selection).stdout).toBe(first.stdout);
|
||||
const invalid = f.run({ docker: { lanes: ["onboard", "session-runtime-context"] } });
|
||||
expect(invalid.status).toBe(1);
|
||||
expect(invalid.stderr).toContain("unable to resolve frozen runtime-context");
|
||||
expect(invalid.stdout).toBe("");
|
||||
expectRejected(invalid, "unable to resolve frozen runtime-context");
|
||||
});
|
||||
|
||||
it.each(["June", "July"])(
|
||||
|
|
@ -891,9 +862,7 @@ describe("frozen admission entry", () => {
|
|||
});
|
||||
rmSync(join(f.tooling.root, "node_modules"), { recursive: true });
|
||||
const rejected = f.run({ consumers: ["agent-bundle-mcp-tools"] });
|
||||
expect(rejected.status).toBe(1);
|
||||
expect(rejected.stderr).toContain("trusted TypeScript parser");
|
||||
expect(rejected.stdout).toBe("");
|
||||
expectRejected(rejected, "trusted TypeScript parser");
|
||||
expect(f.run({ consumers: ["onboard"] }).status).toBe(0);
|
||||
},
|
||||
);
|
||||
|
|
@ -913,12 +882,8 @@ describe("frozen admission entry", () => {
|
|||
},
|
||||
);
|
||||
if (shape === "deleted blob") {
|
||||
const oid = f.selected.git("rev-parse", `${f.selected.sha}:${relative}`);
|
||||
f.selected.git("config", "remote.origin.url", "fixture::unavailable");
|
||||
f.selected.git("config", "remote.origin.promisor", "true");
|
||||
f.selected.git("config", "extensions.partialClone", "origin");
|
||||
f.selected.git("config", "protocol.fixture.allow", "always");
|
||||
rmSync(join(f.selected.root, ".git/objects", oid.slice(0, 2), oid.slice(2)));
|
||||
configureUnavailablePromisor(f.selected);
|
||||
removeBlob(f.selected, relative);
|
||||
} else if (shape === "dirty absent metadata") {
|
||||
mkdirSync(dirname(join(f.selected.root, relative)), { recursive: true });
|
||||
writeFileSync(join(f.selected.root, relative), "dirty supported decoy");
|
||||
|
|
@ -939,11 +904,10 @@ describe("frozen admission entry", () => {
|
|||
status: "NOT RUN",
|
||||
});
|
||||
} else {
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).toContain(
|
||||
expectRejected(
|
||||
result,
|
||||
shape === "unknown catalog" ? "inert scenario catalog" : "unable to read selected source",
|
||||
);
|
||||
expect(result.stdout).toBe("");
|
||||
}
|
||||
},
|
||||
);
|
||||
|
|
@ -951,7 +915,6 @@ describe("frozen admission entry", () => {
|
|||
it.each([
|
||||
{ scenarios: "acpx-openclaw-tools-bridge", allow: true, supported: false },
|
||||
{ scenarios: "base acpx-openclaw-tools-bridge", allow: true, supported: true },
|
||||
{ scenarios: "acpx-openclaw-tools-bridge", allow: false, supported: false },
|
||||
{ scenarios: "base acpx-openclaw-tools-bridge", allow: false, supported: true },
|
||||
])(
|
||||
"preserves inert-only survivor coverage for $scenarios with omissions $allow",
|
||||
|
|
@ -987,9 +950,7 @@ describe("frozen admission entry", () => {
|
|||
{ allowFrozenTargetScenarioOmissions: allow },
|
||||
);
|
||||
if (!allow) {
|
||||
expect(result.status, result.stderr).toBe(1);
|
||||
expect(result.stderr).toContain("require authorized scenario omissions");
|
||||
expect(result.stdout).toBe("");
|
||||
expectRejected(result, "require authorized scenario omissions");
|
||||
return;
|
||||
}
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
|
|
@ -1061,12 +1022,9 @@ describe("frozen admission entry", () => {
|
|||
{ consumer: "live-codex-harness-gpt56-sol-docker", status: "NOT RUN" },
|
||||
{ consumer: "fs-safe-native", mode: "not-applicable" },
|
||||
]);
|
||||
const oid = f.selected.git("rev-parse", `${f.selected.sha}:${catalog}`);
|
||||
rmSync(join(f.selected.root, ".git/objects", oid.slice(0, 2), oid.slice(2)));
|
||||
removeBlob(f.selected, catalog);
|
||||
const rejected = f.run(selection);
|
||||
expect(rejected.status).toBe(1);
|
||||
expect(rejected.stderr).toContain("unable to read selected source");
|
||||
expect(rejected.stdout).toBe("");
|
||||
expectRejected(rejected, "unable to read selected source");
|
||||
expect(f.run(selection, { allowFrozenTargetScenarioOmissions: false }).status).toBe(0);
|
||||
});
|
||||
|
||||
|
|
@ -1090,9 +1048,7 @@ describe("frozen admission entry", () => {
|
|||
);
|
||||
const result = f.run({ fsSafeNative: true });
|
||||
if (requiresDefaults) {
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).toContain("missing fs-safe defaults source");
|
||||
expect(result.stdout).toBe("");
|
||||
expectRejected(result, "missing fs-safe defaults source");
|
||||
} else {
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
expect(JSON.parse(result.stdout).contracts).toEqual([
|
||||
|
|
@ -1186,23 +1142,15 @@ describe("frozen admission entry", () => {
|
|||
if (consumer === "kitchen-sink-plugin") {
|
||||
expect(args).toContain("OPENCLAW_FROZEN_TARGET_PLUGIN_UNINSTALL_MODE=legacy");
|
||||
expect(contract.modes.OPENCLAW_FROZEN_TARGET_PLUGIN_UNINSTALL_MODE).toBe("legacy");
|
||||
const capabilityOid = f.selected.git("rev-parse", `${f.selected.sha}:${pluginAssertions}`);
|
||||
rmSync(
|
||||
join(f.selected.root, ".git/objects", capabilityOid.slice(0, 2), capabilityOid.slice(2)),
|
||||
);
|
||||
const capabilityOid = removeBlob(f.selected, pluginAssertions);
|
||||
const unreadable = f.run({ consumers: [consumer] });
|
||||
expect(unreadable.status).toBe(1);
|
||||
expect(unreadable.stderr).toContain("unable to read selected source");
|
||||
expect(unreadable.stdout).toBe("");
|
||||
expectRejected(unreadable, "unable to read selected source");
|
||||
expect(f.run({ consumers: ["onboard"] }).status).toBe(0);
|
||||
expect(f.selected.git("hash-object", "-w", pluginAssertions)).toBe(capabilityOid);
|
||||
}
|
||||
const oid = f.selected.git("rev-parse", `${f.selected.sha}:${files[0]}`);
|
||||
rmSync(join(f.selected.root, ".git/objects", oid.slice(0, 2), oid.slice(2)));
|
||||
removeBlob(f.selected, files[0]!);
|
||||
const rejected = f.run({ consumers: [consumer] });
|
||||
expect(rejected.status).toBe(1);
|
||||
expect(rejected.stderr).toContain("unable to read selected source");
|
||||
expect(rejected.stdout).toBe("");
|
||||
expectRejected(rejected, "unable to read selected source");
|
||||
});
|
||||
|
||||
it.each(["absent", "missing object"])(
|
||||
|
|
@ -1215,54 +1163,30 @@ describe("frozen admission entry", () => {
|
|||
true,
|
||||
);
|
||||
if (shape === "missing object") {
|
||||
const oid = f.selected.git("rev-parse", `${f.selected.sha}:${manifest}`);
|
||||
rmSync(join(f.selected.root, ".git/objects", oid.slice(0, 2), oid.slice(2)));
|
||||
removeBlob(f.selected, manifest);
|
||||
}
|
||||
for (const allow of [true, false]) {
|
||||
const result = f.run(
|
||||
{ consumers: ["codex-on-demand"] },
|
||||
{ allowFrozenTargetScenarioOmissions: allow },
|
||||
);
|
||||
expect(result.status, result.stderr).toBe(1);
|
||||
expect(result.stderr).toContain(
|
||||
expectRejected(
|
||||
result,
|
||||
shape === "absent" ? "missing required contract file" : "unable to read selected source",
|
||||
);
|
||||
expect(result.stdout).toBe("");
|
||||
}
|
||||
expect(f.run({ consumers: ["onboard"] }).status).toBe(0);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["missing run", "missing imported data", "missing companion blob", "complete"])(
|
||||
"checks the selected survivor directory closure: %s",
|
||||
(shape) => {
|
||||
const dir = "scripts/e2e/lib/upgrade-survivor";
|
||||
it.each(["run.sh", "config-recipe/models-openai.json"])(
|
||||
"rejects the selected survivor directory missing %s",
|
||||
(path) => {
|
||||
const files = survivorFiles();
|
||||
const companion = "scripts/e2e/lib/plugin-index-sqlite.mjs";
|
||||
if (shape === "missing run") {
|
||||
delete files[`${dir}/run.sh`];
|
||||
}
|
||||
if (shape === "missing imported data") {
|
||||
delete files[`${dir}/config-recipe/models-openai.json`];
|
||||
}
|
||||
delete files[`scripts/e2e/lib/upgrade-survivor/${path}`];
|
||||
const f = fixture(files);
|
||||
if (shape === "missing companion blob") {
|
||||
const oid = f.selected.git("rev-parse", `${f.selected.sha}:${companion}`);
|
||||
rmSync(join(f.selected.root, ".git/objects", oid.slice(0, 2), oid.slice(2)));
|
||||
}
|
||||
const result = f.run({ consumers: ["upgrade-survivor"] });
|
||||
if (shape === "complete") {
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
expect(JSON.parse(result.stdout).contracts[0].modes.releaseTrain).toBe("extended-stable");
|
||||
} else {
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stderr).toContain(
|
||||
shape === "missing companion blob"
|
||||
? "unable to read selected source"
|
||||
: "missing required contract file",
|
||||
);
|
||||
expect(result.stdout).toBe("");
|
||||
}
|
||||
expectRejected(result, "missing required contract file");
|
||||
},
|
||||
);
|
||||
|
||||
|
|
@ -1276,7 +1200,6 @@ describe("frozen admission entry", () => {
|
|||
])("rejects malformed or widened admission input %#", (overrides) => {
|
||||
const f = fixture();
|
||||
const result = f.run({}, overrides);
|
||||
expect(result.status).toBe(1);
|
||||
expect(result.stdout).toBe("");
|
||||
expectRejected(result);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -101,10 +101,7 @@ function fixture(layout: "named" | "direct" = "named") {
|
|||
}
|
||||
|
||||
describe("QA profile failure diagnostics", () => {
|
||||
it.each([
|
||||
{ label: "one of two planned shards", matrix: plan, missing: ["shard-01"] },
|
||||
{ label: "one planned shard", matrix: { include: [plan.include[1]] }, missing: [] },
|
||||
])("retains a directly extracted survivor with $label", ({ matrix, missing }) => {
|
||||
it("retains a directly extracted survivor when another planned shard is missing", () => {
|
||||
const f = fixture("direct");
|
||||
const statusPath = f.writeShard(1, {
|
||||
...f.status(1),
|
||||
|
|
@ -129,10 +126,7 @@ describe("QA profile failure diagnostics", () => {
|
|||
...payloadFiles.map(([relativePath]) => path.join(f.input, relativePath)),
|
||||
];
|
||||
const originalInputs = inputPaths.map((filePath) => readFileSync(filePath));
|
||||
const { result } = f.collect({
|
||||
PLAN_MATRIX_JSON: JSON.stringify(matrix),
|
||||
SHARD_COUNT: String(matrix.include.length),
|
||||
});
|
||||
const { result } = f.collect();
|
||||
expect(result.shards).toEqual([
|
||||
{
|
||||
id: "shard-02",
|
||||
|
|
@ -148,8 +142,8 @@ describe("QA profile failure diagnostics", () => {
|
|||
stages: { AGGREGATE_OUTCOME: "failure", FINALIZE_OUTCOME: "skipped" },
|
||||
statusFiles: 1,
|
||||
evidenceFiles: 1,
|
||||
missingStatuses: missing,
|
||||
missingEvidence: missing,
|
||||
missingStatuses: ["shard-01"],
|
||||
missingEvidence: ["shard-01"],
|
||||
issues: [],
|
||||
});
|
||||
expect(inputPaths.map((filePath) => readFileSync(filePath))).toEqual(originalInputs);
|
||||
|
|
@ -289,7 +283,7 @@ describe("QA profile failure diagnostics", () => {
|
|||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === "win32").each([null, {}, [], "invalid", 1, true])(
|
||||
it.skipIf(process.platform === "win32").each([null, {}, [], "invalid"])(
|
||||
"preserves the previous jq admission for shard shape %#",
|
||||
(shard) => {
|
||||
const f = fixture();
|
||||
|
|
@ -331,7 +325,6 @@ describe("QA profile failure diagnostics", () => {
|
|||
|
||||
it.each([
|
||||
[0, false, "none"],
|
||||
[1, false, "none"],
|
||||
[124, false, "none"],
|
||||
[137, false, "none"],
|
||||
[124, true, "term"],
|
||||
|
|
@ -353,7 +346,7 @@ describe("QA profile failure diagnostics", () => {
|
|||
},
|
||||
);
|
||||
|
||||
it.skipIf(process.platform === "win32").each(["0", "1", "124", "137"])(
|
||||
it.skipIf(process.platform === "win32").each(["0", "137"])(
|
||||
"does not change the existing allow_failures decision for exit %s",
|
||||
(code) => {
|
||||
for (const allowFailures of ["false", "true"]) {
|
||||
|
|
@ -370,14 +363,13 @@ describe("QA profile failure diagnostics", () => {
|
|||
},
|
||||
);
|
||||
|
||||
it.each(
|
||||
(["named", "direct"] as const).flatMap((layout) =>
|
||||
['{"untrusted-status-sentinel":', "null", "[]", "x".repeat(65 * 1024)].map((payload) => ({
|
||||
layout,
|
||||
payload,
|
||||
})),
|
||||
),
|
||||
)("bounds malformed $layout status input %#", ({ layout, payload }) => {
|
||||
it.each([
|
||||
{ layout: "named", payload: '{"untrusted-status-sentinel":' },
|
||||
{ layout: "named", payload: "null" },
|
||||
{ layout: "named", payload: "[]" },
|
||||
{ layout: "named", payload: "x".repeat(65 * 1024) },
|
||||
{ layout: "direct", payload: '{"untrusted-status-sentinel":' },
|
||||
] as const)("bounds malformed $layout status input %#", ({ layout, payload }) => {
|
||||
const f = fixture(layout);
|
||||
const source = f.writeShard(0, payload);
|
||||
const original = readFileSync(source);
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import {
|
|||
awaitParentAuthorization,
|
||||
createReleaseApprovalReceipt,
|
||||
downloadReleaseApprovalReceipt,
|
||||
releaseApprovalArtifactName,
|
||||
validateReleaseApprovalReceipt,
|
||||
verifyReleaseApprovalReceipt,
|
||||
} from "../../scripts/release-approval-receipt.mjs";
|
||||
|
|
@ -131,21 +130,13 @@ describe("release approval receipt", () => {
|
|||
];
|
||||
});
|
||||
expect(receipt).toEqual(fixture().receipt);
|
||||
expect(Object.keys(receipt)).toEqual(Object.keys(fixture().receipt));
|
||||
expect(releaseApprovalArtifactName({ parentRunId: "10", parentRunAttempt: "2" })).toBe(
|
||||
"openclaw-release-approval-v1-10-2",
|
||||
);
|
||||
expect(validateReleaseApprovalReceipt(receipt)).toBe(receipt);
|
||||
});
|
||||
|
||||
it.each(["github-actions[bot]", "reviewer[BoT]", "", " "])(
|
||||
"rejects an invalid last approver %j",
|
||||
(login) => {
|
||||
expect(() => createReleaseApprovalReceipt(env, () => [approved(), approved(login)])).toThrow(
|
||||
/human login/u,
|
||||
);
|
||||
},
|
||||
);
|
||||
it.each(["reviewer[BoT]", " "])("rejects an invalid last approver %j", (login) => {
|
||||
expect(() => createReleaseApprovalReceipt(env, () => [approved(), approved(login)])).toThrow(
|
||||
/human login/u,
|
||||
);
|
||||
});
|
||||
|
||||
it("rejects approval histories without an approved npm-release entry", () => {
|
||||
expect(() =>
|
||||
|
|
@ -188,11 +179,6 @@ describe("release approval receipt", () => {
|
|||
);
|
||||
});
|
||||
|
||||
it("verifies approval while the parent publish job is still running", () => {
|
||||
const input = fixture();
|
||||
expect(verifyReleaseApprovalReceipt(input)).toBe(input.receipt);
|
||||
});
|
||||
|
||||
it("rejects extra receipt keys", () => {
|
||||
const input = fixture();
|
||||
expect(() =>
|
||||
|
|
@ -394,10 +380,8 @@ describe("release approval artifact download", () => {
|
|||
);
|
||||
});
|
||||
|
||||
describe.each([
|
||||
["ClawHub", "openclaw-clawhub-parent-authorization-v2-10-2-30-1", false],
|
||||
["npm", "openclaw-release-approval-v1-10-2", true],
|
||||
])("%s parent authorization wait", (_target, name, requireInProgress) => {
|
||||
describe("parent authorization wait", () => {
|
||||
const name = "openclaw-release-approval-v1-10-2";
|
||||
const listing = `actions/runs/10/artifacts?name=${name}&per_page=100`;
|
||||
const authorization = {
|
||||
name,
|
||||
|
|
@ -408,7 +392,7 @@ describe.each([
|
|||
parentRunId: "10",
|
||||
parentRunAttempt: "2",
|
||||
expectedArtifactName: name,
|
||||
requireInProgress,
|
||||
requireInProgress: true,
|
||||
toolingSha: "a".repeat(40),
|
||||
sleep: async () => {},
|
||||
};
|
||||
|
|
@ -437,11 +421,11 @@ describe.each([
|
|||
);
|
||||
});
|
||||
|
||||
it.each(["success", "cancelled", "failure"])(
|
||||
"requires a live npm parent even when its artifact exists (%s)",
|
||||
async (conclusion) => {
|
||||
const runGhJson = api([[authorization]], { status: "completed", conclusion });
|
||||
const result = awaitParentAuthorization({ ...params, runGhJson });
|
||||
it.each([true, false])(
|
||||
"accepts detached authorization only when a live parent is not required (%s)",
|
||||
async (requireInProgress) => {
|
||||
const runGhJson = api([[authorization]], { status: "completed", conclusion: "failure" });
|
||||
const result = awaitParentAuthorization({ ...params, runGhJson, requireInProgress });
|
||||
if (requireInProgress) {
|
||||
await expect(result).rejects.toThrow(/completed\//u);
|
||||
} else {
|
||||
|
|
@ -485,7 +469,6 @@ describe("parent authorization CLI", () => {
|
|||
it.each([
|
||||
["wait-npm-authorization", "in_progress", null, 0, "openclaw-release-approval-v1-10-2"],
|
||||
["wait-npm-authorization", "completed", "success", 1, "openclaw-release-approval-v1-10-2"],
|
||||
["wait-npm-authorization", "completed", "cancelled", 1, "openclaw-release-approval-v1-10-2"],
|
||||
[
|
||||
"wait-clawhub-authorization",
|
||||
"completed",
|
||||
|
|
|
|||
|
|
@ -2,7 +2,6 @@ import { readFileSync } from "node:fs";
|
|||
import { runInNewContext } from "node:vm";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { parse } from "yaml";
|
||||
import { validateReleasePublishParentRun } from "../../scripts/release-tooling-identity.mjs";
|
||||
|
||||
type WorkflowStep = {
|
||||
name: string;
|
||||
|
|
@ -137,14 +136,6 @@ describe("release approval workflow contracts", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
["plugin-npm-release", "publish_plugins_npm"],
|
||||
["plugin-npm-release", "trusted_publisher_preflight"],
|
||||
["openclaw-npm-release", "publish_openclaw_npm"],
|
||||
])("%s %s uses the npm trusted-publisher environment", (workflow, jobId) => {
|
||||
expect(requireJob(workflow, jobId).environment).toBe("npm-publish");
|
||||
});
|
||||
|
||||
it.each([
|
||||
[
|
||||
"openclaw-npm-release",
|
||||
|
|
@ -297,36 +288,6 @@ describe("release approval workflow contracts", () => {
|
|||
expect(policyFor("", bot)).toBe("active-or-failure");
|
||||
}
|
||||
|
||||
const sha = "a".repeat(40);
|
||||
const ref = `release-publish/${sha.slice(0, 12)}-123`;
|
||||
const verifyParent = (status: string, conclusion: string | null) =>
|
||||
validateReleasePublishParentRun({
|
||||
identity: { ref, fullRef: `refs/tags/${ref}`, sha },
|
||||
releasePublishFullRef: `refs/tags/${ref}`,
|
||||
releasePublishParentStatePolicy: receiptPolicy,
|
||||
releasePublishRef: ref,
|
||||
releasePublishRunAttempt: "2",
|
||||
releasePublishRunId: "67890",
|
||||
repository: "openclaw/openclaw",
|
||||
run: {
|
||||
id: 67890,
|
||||
run_attempt: 2,
|
||||
repository: { full_name: "openclaw/openclaw" },
|
||||
path: `.github/workflows/openclaw-release-publish.yml@refs/tags/${ref}`,
|
||||
event: "workflow_dispatch",
|
||||
head_branch: ref,
|
||||
head_sha: sha,
|
||||
status,
|
||||
conclusion,
|
||||
},
|
||||
});
|
||||
// The parent failed after the child's authorization wait: refuse before npm I/O.
|
||||
expect(() => verifyParent("completed", "failure")).toThrow(
|
||||
"release publish parent run state is not allowed by active",
|
||||
);
|
||||
expect(() => verifyParent("completed", "success")).toThrow();
|
||||
expect(() => verifyParent("in_progress", null)).not.toThrow();
|
||||
|
||||
const script = publish.run;
|
||||
const mutation =
|
||||
workflow === "openclaw-npm-release"
|
||||
|
|
|
|||
|
|
@ -48,7 +48,7 @@ describe("reviewed release channel waiver", () => {
|
|||
expect(releaseWaivedIntegrationChannels({ ...approved, telegramWaiver: "" })).toEqual([]);
|
||||
});
|
||||
|
||||
it.each(["qa-live-matrix", "qa-matrix", "matrix", "MATRIX", "qa-live-matrix,qa-live-buzz"])(
|
||||
it.each(["qa-matrix", "MATRIX", "qa-live-buzz,qa-live-matrix"])(
|
||||
"rejects explicit Matrix selection %s under the combined 9.5 declaration",
|
||||
(liveSuiteFilter) => {
|
||||
const inputs = { ...approved, liveSuiteFilter };
|
||||
|
|
|
|||
|
|
@ -1,20 +1,17 @@
|
|||
import { spawnSync } from "node:child_process";
|
||||
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import { join, resolve } from "node:path";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js";
|
||||
|
||||
const roots: string[] = [];
|
||||
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
|
||||
const workflowRef = "release-publish/aaaaaaaaaaaa-123";
|
||||
const workflowSha = "a".repeat(40);
|
||||
const releaseTag = "v2026.9.5";
|
||||
const repository = "openclaw/openclaw";
|
||||
|
||||
afterEach(() => {
|
||||
for (const root of roots.splice(0)) {
|
||||
rmSync(root, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
const dispatchCommand =
|
||||
'require_clawhub_dispatch_available "$WORKFLOW_REF" "$WORKFLOW"\ndispatch_workflow_at_ref "$WORKFLOW_REF" "$PARENT_WORKFLOW_SHA" "$WORKFLOW" -f release_tag="$RELEASE_TAG"';
|
||||
|
||||
function fixture({
|
||||
workflow = "plugin-clawhub-release.yml",
|
||||
|
|
@ -29,9 +26,8 @@ function fixture({
|
|||
cancellationFails = false,
|
||||
publisherRunning = false,
|
||||
} = {}) {
|
||||
const root = mkdtempSync(join(tmpdir(), "release-clawhub-children-"));
|
||||
const root = tempDirs.make("release-clawhub-children-");
|
||||
const currentWorkflowRef = workflow === "plugin-clawhub-new.yml" ? "main" : workflowRef;
|
||||
roots.push(root);
|
||||
mkdirSync(join(root, "bin"));
|
||||
const child = {
|
||||
id: 91,
|
||||
|
|
@ -108,7 +104,7 @@ if (args[0] === 'run' && args[1] === 'list') {
|
|||
);
|
||||
return {
|
||||
root,
|
||||
run(command: string) {
|
||||
run(command = dispatchCommand) {
|
||||
const result = spawnSync("bash", ["-c", `source "$HELPER_SCRIPT"\n${command}`], {
|
||||
encoding: "utf8",
|
||||
env: {
|
||||
|
|
@ -146,9 +142,7 @@ describe("ClawHub child lifecycle", () => {
|
|||
"reclaims an older failed parent's waiting %s before dispatch",
|
||||
(workflow) => {
|
||||
const f = fixture({ workflow });
|
||||
const result = f.run(
|
||||
'require_clawhub_dispatch_available "$WORKFLOW_REF" "$WORKFLOW"\ndispatch_workflow_at_ref "$WORKFLOW_REF" "$PARENT_WORKFLOW_SHA" "$WORKFLOW" -f release_tag="$RELEASE_TAG"',
|
||||
);
|
||||
const result = f.run();
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
const cancellation = result.calls.findIndex((args) => args[1] === "cancel");
|
||||
const dispatch = result.calls.findIndex((args) =>
|
||||
|
|
@ -167,9 +161,7 @@ describe("ClawHub child lifecycle", () => {
|
|||
{ publisherRunning: true, label: "active publisher" },
|
||||
{ cancellationFails: true, label: "failed cancellation" },
|
||||
])("does not dispatch past $label", (options) => {
|
||||
const result = fixture(options).run(
|
||||
'require_clawhub_dispatch_available "$WORKFLOW_REF" "$WORKFLOW"\ndispatch_workflow_at_ref "$WORKFLOW_REF" "$PARENT_WORKFLOW_SHA" "$WORKFLOW" -f release_tag="$RELEASE_TAG"',
|
||||
);
|
||||
const result = fixture(options).run();
|
||||
expect(result.status).not.toBe(0);
|
||||
expect(result.calls.some((args) => args.some((arg) => arg.endsWith("/dispatches")))).toBe(
|
||||
false,
|
||||
|
|
@ -179,55 +171,38 @@ describe("ClawHub child lifecycle", () => {
|
|||
}
|
||||
});
|
||||
|
||||
it.each([
|
||||
["plugin-clawhub-release.yml", false],
|
||||
["plugin-clawhub-release.yml", true],
|
||||
["plugin-clawhub-new.yml", false],
|
||||
["plugin-clawhub-new.yml", true],
|
||||
] as const)(
|
||||
"leaves another tag's waiting %s alone (same tooling: %s)",
|
||||
(workflow, sameToolingRef) => {
|
||||
const result = fixture({ workflow, titleTag: "v2026.9.4", sameToolingRef }).run(
|
||||
'require_clawhub_dispatch_available "$WORKFLOW_REF" "$WORKFLOW"\ndispatch_workflow_at_ref "$WORKFLOW_REF" "$PARENT_WORKFLOW_SHA" "$WORKFLOW" -f release_tag="$RELEASE_TAG"',
|
||||
);
|
||||
it.each(["plugin-clawhub-release.yml", "plugin-clawhub-new.yml"])(
|
||||
"leaves another tag's waiting %s alone on the same tooling ref",
|
||||
(workflow) => {
|
||||
const result = fixture({ workflow, titleTag: "v2026.9.4", sameToolingRef: true }).run();
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
expect(result.calls.some((args) => args[1] === "cancel")).toBe(false);
|
||||
},
|
||||
);
|
||||
|
||||
it.each(["plugin-clawhub-release.yml", "plugin-clawhub-new.yml"])(
|
||||
"leaves same-tag validation on the same tooling ref independent in %s",
|
||||
(workflow) => {
|
||||
const result = fixture({
|
||||
workflow,
|
||||
sameToolingRef: true,
|
||||
validation: true,
|
||||
childStatus: "in_progress",
|
||||
}).run(
|
||||
'require_clawhub_dispatch_available "$WORKFLOW_REF" "$WORKFLOW"\ndispatch_workflow_at_ref "$WORKFLOW_REF" "$PARENT_WORKFLOW_SHA" "$WORKFLOW" -f release_tag="$RELEASE_TAG"',
|
||||
);
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
expect(result.calls.some((args) => args[1] === "cancel")).toBe(false);
|
||||
},
|
||||
);
|
||||
it("leaves same-tag validation on the same tooling ref independent", () => {
|
||||
const result = fixture({
|
||||
sameToolingRef: true,
|
||||
validation: true,
|
||||
childStatus: "in_progress",
|
||||
}).run();
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
expect(result.calls.some((args) => args[1] === "cancel")).toBe(false);
|
||||
});
|
||||
|
||||
it("preserves bootstrap's existing independent slots for unidentified main runs", () => {
|
||||
const result = fixture({
|
||||
workflow: "plugin-clawhub-new.yml",
|
||||
sameToolingRef: true,
|
||||
legacyTitle: true,
|
||||
}).run(
|
||||
'require_clawhub_dispatch_available "$WORKFLOW_REF" "$WORKFLOW"\ndispatch_workflow_at_ref "$WORKFLOW_REF" "$PARENT_WORKFLOW_SHA" "$WORKFLOW" -f release_tag="$RELEASE_TAG"',
|
||||
);
|
||||
}).run();
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
expect(result.calls.some((args) => args[1] === "cancel")).toBe(false);
|
||||
});
|
||||
|
||||
it("cleans up immediately recorded children after a later dispatch step fails", () => {
|
||||
const f = fixture({ titleTag: "v2026.9.4" });
|
||||
const dispatched = f.run(
|
||||
'require_clawhub_dispatch_available "$WORKFLOW_REF" "$WORKFLOW"\ndispatch_workflow_at_ref "$WORKFLOW_REF" "$PARENT_WORKFLOW_SHA" "$WORKFLOW" -f release_tag="$RELEASE_TAG"\nexit 1',
|
||||
);
|
||||
const dispatched = f.run(`${dispatchCommand}\nexit 1`);
|
||||
expect(dispatched.status).toBe(1);
|
||||
expect(dispatched.savedEnv).toContain("=92");
|
||||
const cleanup = f.run('source "$GITHUB_ENV"\ncleanup_clawhub_children');
|
||||
|
|
|
|||
|
|
@ -81,7 +81,6 @@ describe("release evidence API reads", () => {
|
|||
["GraphQL", ["api", "graphql"], "HTTP 502"],
|
||||
["forbidden", ["api", "repos/openclaw/openclaw/actions/runs/42"], "HTTP 403"],
|
||||
["rate limited", ["api", "repos/openclaw/openclaw/actions/runs/42"], "HTTP 429"],
|
||||
["not found", ["api", "repos/openclaw/openclaw/actions/runs/42"], "HTTP 404"],
|
||||
])("does not retry %s", (_label, args, stderr) => {
|
||||
const wait = vi.spyOn(Atomics, "wait").mockReturnValue("timed-out");
|
||||
const failure = Object.assign(new Error("gh failed"), { stderr });
|
||||
|
|
@ -93,10 +92,10 @@ describe("release evidence API reads", () => {
|
|||
expect(wait).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["ECONNRESET", "ETIMEDOUT", "unexpected EOF"])("recovers a network error: %s", (code) => {
|
||||
it("recovers a network error", () => {
|
||||
vi.spyOn(Atomics, "wait").mockReturnValue("timed-out");
|
||||
transport.read.mockImplementationOnce(() => {
|
||||
throw Object.assign(new Error(code), { code });
|
||||
throw Object.assign(new Error("connection failed"), { code: "ECONNRESET" });
|
||||
});
|
||||
transport.read.mockReturnValue('{"id":42}');
|
||||
expect(createReleaseEvidenceClient("openclaw/openclaw").getRunAttempt("42", 1)).toEqual({
|
||||
|
|
|
|||
|
|
@ -81,7 +81,6 @@ describe("release publish preflight operator interface", () => {
|
|||
);
|
||||
|
||||
it.each([
|
||||
{ tag: "v2026.9.5-beta.1", npmDistTag: "beta" },
|
||||
{ tag: "v2026.9.5", npmDistTag: "beta" },
|
||||
{ tag: "v2026.8.33", npmDistTag: "extended-stable" },
|
||||
])("keeps $tag on $npmDistTag behind Docker before GitHub activation", (input) => {
|
||||
|
|
@ -116,7 +115,7 @@ describe("release publish preflight operator interface", () => {
|
|||
},
|
||||
);
|
||||
|
||||
it.each(["maybe", "TRUE", "0"])(
|
||||
it.each(["TRUE", "0"])(
|
||||
"rejects ambiguous publish-openclaw-npm=%s before observation",
|
||||
(value) => {
|
||||
expect(() =>
|
||||
|
|
|
|||
|
|
@ -13,6 +13,27 @@ import { createNestedGitEnv, writeJsonFile } from "../helpers/temp-repo.js";
|
|||
const temps = useAutoCleanupTempDirTracker(afterEach);
|
||||
const version = "2026.9.5";
|
||||
const sourceSha = "a".repeat(40);
|
||||
const githubOptions = {
|
||||
repository: "openclaw/openclaw",
|
||||
releaseTag: `v${version}`,
|
||||
sourceSha,
|
||||
npmDistTag: "latest",
|
||||
};
|
||||
const pluginOptions = {
|
||||
version,
|
||||
npmDistTag: "latest",
|
||||
publishOpenclawNpm: false,
|
||||
plugins: [
|
||||
{
|
||||
extensionId: "example",
|
||||
packageDir: "extensions/example",
|
||||
packageName: "@openclaw/example",
|
||||
version,
|
||||
channel: "stable",
|
||||
publishTag: "latest",
|
||||
},
|
||||
],
|
||||
} satisfies Parameters<typeof observeReleaseNpmState>[0];
|
||||
afterEach(() => vi.unstubAllGlobals());
|
||||
|
||||
describe("release publication state", () => {
|
||||
|
|
@ -30,21 +51,7 @@ describe("release publication state", () => {
|
|||
}),
|
||||
),
|
||||
);
|
||||
const result = await observeReleaseNpmState({
|
||||
version,
|
||||
npmDistTag: "latest",
|
||||
publishOpenclawNpm: false,
|
||||
plugins: [
|
||||
{
|
||||
extensionId: "example",
|
||||
packageDir: "extensions/example",
|
||||
packageName: "@openclaw/example",
|
||||
version,
|
||||
channel: "stable",
|
||||
publishTag: "latest",
|
||||
},
|
||||
],
|
||||
});
|
||||
const result = await observeReleaseNpmState(pluginOptions);
|
||||
expect(result.gates).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: "npm.package.@openclaw/example",
|
||||
|
|
@ -77,21 +84,7 @@ describe("release publication state", () => {
|
|||
}),
|
||||
),
|
||||
);
|
||||
const result = await observeReleaseNpmState({
|
||||
version,
|
||||
npmDistTag: "latest",
|
||||
publishOpenclawNpm: false,
|
||||
plugins: [
|
||||
{
|
||||
extensionId: "example",
|
||||
packageDir: "extensions/example",
|
||||
packageName: "@openclaw/example",
|
||||
version,
|
||||
channel: "stable",
|
||||
publishTag: "latest",
|
||||
},
|
||||
],
|
||||
});
|
||||
const result = await observeReleaseNpmState(pluginOptions);
|
||||
expect(result.gates).toContainEqual(
|
||||
expect.objectContaining({
|
||||
id: "npm.package.@openclaw/example",
|
||||
|
|
@ -261,10 +254,7 @@ function observeRuns(params: {
|
|||
onProgress?: (message: string) => void;
|
||||
}) {
|
||||
return observeReleaseGitHubState({
|
||||
repository: "openclaw/openclaw",
|
||||
releaseTag: `v${version}`,
|
||||
sourceSha,
|
||||
npmDistTag: "latest",
|
||||
...githubOptions,
|
||||
onProgress: params.onProgress,
|
||||
runGh: (args) => {
|
||||
const endpoint = args[1];
|
||||
|
|
@ -325,10 +315,7 @@ describe("release concurrency observations", () => {
|
|||
return "[]";
|
||||
});
|
||||
const result = observeReleaseGitHubState({
|
||||
repository: "openclaw/openclaw",
|
||||
releaseTag: `v${version}`,
|
||||
sourceSha,
|
||||
npmDistTag: "latest",
|
||||
...githubOptions,
|
||||
budgetMs: 100,
|
||||
now: () => now,
|
||||
runGh,
|
||||
|
|
@ -357,10 +344,7 @@ describe("release concurrency observations", () => {
|
|||
|
||||
it("retains a gh stderr failure and exact unread command without hiding known blockers", () => {
|
||||
const result = observeReleaseGitHubState({
|
||||
repository: "openclaw/openclaw",
|
||||
releaseTag: `v${version}`,
|
||||
sourceSha,
|
||||
npmDistTag: "latest",
|
||||
...githubOptions,
|
||||
runGh(args) {
|
||||
if (args[1]?.includes("/plugin-npm-release.yml/") && args[1].includes("status=queued")) {
|
||||
return JSON.stringify({
|
||||
|
|
@ -413,10 +397,7 @@ describe("release concurrency observations", () => {
|
|||
: JSON.stringify({ total_count: 0, workflow_runs: [] }),
|
||||
);
|
||||
const result = observeReleaseGitHubState({
|
||||
repository: "openclaw/openclaw",
|
||||
releaseTag: `v${version}`,
|
||||
sourceSha,
|
||||
npmDistTag: "latest",
|
||||
...githubOptions,
|
||||
runGh,
|
||||
});
|
||||
expect(result.release).toMatchObject(release);
|
||||
|
|
@ -425,10 +406,7 @@ describe("release concurrency observations", () => {
|
|||
|
||||
it("replays a failed filtered release-inventory read with its exact --jq filter", () => {
|
||||
const result = observeReleaseGitHubState({
|
||||
repository: "openclaw/openclaw",
|
||||
releaseTag: `v${version}`,
|
||||
sourceSha,
|
||||
npmDistTag: "latest",
|
||||
...githubOptions,
|
||||
runGh(args) {
|
||||
if (args[1]?.includes("/releases/tags/")) {
|
||||
throw new Error("HTTP 404: Not Found");
|
||||
|
|
|
|||
|
|
@ -392,7 +392,6 @@ describe("release:stable post-publication CLI", () => {
|
|||
});
|
||||
|
||||
it.each([
|
||||
{ label: "neither asset", names: [] },
|
||||
{ label: "only the evidence", names: [`openclaw-${RELEASE}-stable-main-closeout.json`] },
|
||||
{
|
||||
label: "only the checksum",
|
||||
|
|
|
|||
|
|
@ -320,8 +320,8 @@ describe("release:stable CLI", () => {
|
|||
},
|
||||
);
|
||||
|
||||
it.each(["unknown", "stableSoakWaiver", "laneWaiver"])(
|
||||
"refuses retired or unknown state field %s without overwriting recovery evidence",
|
||||
it.each(["stableSoakWaiver", "laneWaiver"])(
|
||||
"refuses retired state field %s without overwriting recovery evidence",
|
||||
(field) => {
|
||||
const release = fixture();
|
||||
const state = phaseState("validate");
|
||||
|
|
|
|||
|
|
@ -23,20 +23,12 @@ describe("full parent publication verifier entrypoint", () => {
|
|||
it.each([
|
||||
"none",
|
||||
"no-publish",
|
||||
// Detailed fault matrices belong to plugin-npm-publication-readback.test.ts.
|
||||
"missing-tarball",
|
||||
"conflicting-bytes",
|
||||
"missing-receipt",
|
||||
"missing-planned-job",
|
||||
"prior-missing-tarball",
|
||||
"prior-conflicting-bytes",
|
||||
"prior-archive-identity",
|
||||
])("gates release success on qualified plugin readback: %s", async (fault) => {
|
||||
const root = tempDirs.make("parent-publish-cli-");
|
||||
const fixture = await createNpmPublicationReadbackFixture(
|
||||
root,
|
||||
fault.startsWith("prior-") ? "prior-deferred" : "direct",
|
||||
fault.replace(/^prior-/u, ""),
|
||||
);
|
||||
const fixture = await createNpmPublicationReadbackFixture(root, "direct", fault);
|
||||
const readback = await createPluginNpmPublicationReadback(fixture.options).catch(
|
||||
() => undefined,
|
||||
);
|
||||
|
|
@ -147,15 +139,7 @@ globalThis.fetch = async (url) => {
|
|||
).toMatchObject([{ packageName, verification: "published-registry" }]);
|
||||
} else {
|
||||
expect(result.stderr).toContain(
|
||||
fault === "missing-planned-job"
|
||||
? "planned candidate"
|
||||
: fault === "missing-receipt"
|
||||
? "Expected one consumed"
|
||||
: fault.endsWith("missing-tarball")
|
||||
? "HTTP 404"
|
||||
: fault === "prior-archive-identity"
|
||||
? "archive package identity"
|
||||
: "bytes differ",
|
||||
fault === "missing-planned-job" ? "planned candidate" : "HTTP 404",
|
||||
);
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -161,6 +161,15 @@ globalThis.fetch = async (url, options = {}) => {
|
|||
};
|
||||
}
|
||||
|
||||
function reconcileStatuses(statuses: unknown[]) {
|
||||
return evaluate({
|
||||
eventName: "schedule",
|
||||
responses: {
|
||||
[`${prefix}/commits/${head}/statuses?per_page=100&page=1`]: { body: statuses },
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
describe("automatic security review event resolution", () => {
|
||||
it("automatically resolves the current PR after a rate-limited lookup", () => {
|
||||
const result = evaluate({
|
||||
|
|
@ -377,20 +386,13 @@ describe("automatic security review event resolution", () => {
|
|||
});
|
||||
|
||||
it.each([
|
||||
{ action: "created", body: "/allow-security-sensitive-change" },
|
||||
{
|
||||
action: "created",
|
||||
body: " \r\n /allow-dependencies-change \r\n/allow-security-sensitive-change\n",
|
||||
},
|
||||
{ action: "edited", body: "Removed", previousBody: "/allow-dependencies-change" },
|
||||
{
|
||||
action: "edited",
|
||||
body: "> /allow-security-sensitive-change",
|
||||
previousBody: "/allow-security-sensitive-change",
|
||||
},
|
||||
{ action: "edited", body: "/allow-dependencies-change", previousBody: "Thanks" },
|
||||
{ action: "deleted", body: "/allow-security-sensitive-change" },
|
||||
{ action: "deleted", body: "/allow-dependencies-change\n/allow-security-sensitive-change" },
|
||||
])("reevaluates approval comment activity: %j", ({ action, body, previousBody }) => {
|
||||
expect(
|
||||
evaluate({
|
||||
|
|
@ -411,15 +413,10 @@ describe("automatic security review event resolution", () => {
|
|||
|
||||
it.each([
|
||||
{ action: "created", body: "Thanks" },
|
||||
{ action: "edited", body: "Thanks again", previousBody: "Thanks" },
|
||||
{ action: "deleted", body: "Thanks" },
|
||||
{ action: "created", body: "Please post /allow-dependencies-change" },
|
||||
{ action: "created", body: "> /allow-security-sensitive-change" },
|
||||
{ action: "created", body: "```\n/allow-dependencies-change\n```" },
|
||||
{ action: "created", body: "/allow-dependencies-change-extra" },
|
||||
{ action: "created", body: "/allow-dependencies-change\nThanks" },
|
||||
{ action: "edited", body: "Removed", previousBody: "Please post /allow-dependencies-change" },
|
||||
{ action: "deleted", body: "> /allow-security-sensitive-change" },
|
||||
{ action: "created", body: "/ALLOW-DEPENDENCIES-CHANGE" },
|
||||
{ action: "created", body: " \r\n " },
|
||||
{ action: "deleted", body: null },
|
||||
|
|
@ -454,7 +451,7 @@ describe("automatic security review event resolution", () => {
|
|||
});
|
||||
});
|
||||
|
||||
it.each(["success", "failure", "cancelled"])(
|
||||
it.each(["success", "failure"])(
|
||||
"reevaluates %s CI completion against live workflow and head",
|
||||
(conclusion) => {
|
||||
const result = evaluate({ run: { conclusion } });
|
||||
|
|
@ -619,29 +616,22 @@ describe("scheduled reconciliation", () => {
|
|||
{ state: "pending", created_at: "2026-01-01T23:41:00Z" },
|
||||
{ state: "success", created_at: "2026-01-01T23:30:00Z" },
|
||||
])("ignores newer foreign successes before the newest Actions status: %j", (owned) => {
|
||||
const result = evaluate({
|
||||
eventName: "schedule",
|
||||
responses: {
|
||||
[`${prefix}/commits/${head}/statuses?per_page=100&page=1`]: {
|
||||
body: [
|
||||
{
|
||||
...recordedPullRequest(42),
|
||||
state: "success",
|
||||
created_at: "2026-01-01T23:45:00Z",
|
||||
creator: { login: "foreign-bot[bot]", type: "Bot" },
|
||||
},
|
||||
{
|
||||
...recordedPullRequest(42),
|
||||
state: "success",
|
||||
created_at: "2026-01-01T23:44:00Z",
|
||||
creator: { login: "github-actions[bot]", type: "User" },
|
||||
},
|
||||
{ ...recordedPullRequest(42), ...owned },
|
||||
{ ...recordedPullRequest(42), state: "success", created_at: "2026-01-01T23:20:00Z" },
|
||||
],
|
||||
},
|
||||
const result = reconcileStatuses([
|
||||
{
|
||||
...recordedPullRequest(42),
|
||||
state: "success",
|
||||
created_at: "2026-01-01T23:45:00Z",
|
||||
creator: { login: "foreign-bot[bot]", type: "Bot" },
|
||||
},
|
||||
});
|
||||
{
|
||||
...recordedPullRequest(42),
|
||||
state: "success",
|
||||
created_at: "2026-01-01T23:44:00Z",
|
||||
creator: { login: "github-actions[bot]", type: "User" },
|
||||
},
|
||||
{ ...recordedPullRequest(42), ...owned },
|
||||
{ ...recordedPullRequest(42), state: "success", created_at: "2026-01-01T23:20:00Z" },
|
||||
]);
|
||||
expect(result.status, result.error).toBe(0);
|
||||
expect(result.matrix).toEqual({ include: [{ pr: 42, head }] });
|
||||
expect(result.published).toHaveLength(1);
|
||||
|
|
@ -649,31 +639,17 @@ describe("scheduled reconciliation", () => {
|
|||
|
||||
it.each([
|
||||
{ state: "success", created_at: "2026-01-01T23:41:00Z" },
|
||||
{ state: "failure", created_at: "2026-01-01T23:41:00Z" },
|
||||
{ state: "error", created_at: "2026-01-01T23:41:00Z" },
|
||||
{ state: "success", created_at: completedRun.updated_at },
|
||||
])("does not reselect a result settled at or after CI completion: %j", (status) => {
|
||||
const result = evaluate({
|
||||
eventName: "schedule",
|
||||
responses: {
|
||||
[`${prefix}/commits/${head}/statuses?per_page=100&page=1`]: {
|
||||
body: [{ ...recordedPullRequest(42), context: "OpenClaw/CI-Gate", ...status }],
|
||||
},
|
||||
},
|
||||
});
|
||||
const result = reconcileStatuses([
|
||||
{ ...recordedPullRequest(42), context: "OpenClaw/CI-Gate", ...status },
|
||||
]);
|
||||
expect(result).toMatchObject({ status: 0, matrix: { include: [] }, published: [] });
|
||||
expect(result.requests.some(({ path }) => path.includes("/pulls/"))).toBe(false);
|
||||
});
|
||||
|
||||
it.each([
|
||||
{ state: "success", created_at: "2026-01-01T23:30:00Z" },
|
||||
{ state: "failure", created_at: "2026-01-01T23:30:00Z" },
|
||||
{ state: "error", created_at: "2026-01-01T23:30:00Z" },
|
||||
{
|
||||
state: "pending",
|
||||
created_at: "2026-01-01T23:41:00Z",
|
||||
description: "PR #42: Waiting for CI; review updates automatically",
|
||||
},
|
||||
{
|
||||
state: "pending",
|
||||
created_at: "2026-01-01T23:41:00Z",
|
||||
|
|
@ -685,14 +661,7 @@ describe("scheduled reconciliation", () => {
|
|||
description: "PR #42: CI and security review have not completed",
|
||||
},
|
||||
])("reselects a result older than the latest rerun or any pending status: %j", (status) => {
|
||||
const result = evaluate({
|
||||
eventName: "schedule",
|
||||
responses: {
|
||||
[`${prefix}/commits/${head}/statuses?per_page=100&page=1`]: {
|
||||
body: [{ ...recordedPullRequest(42), ...status }],
|
||||
},
|
||||
},
|
||||
});
|
||||
const result = reconcileStatuses([{ ...recordedPullRequest(42), ...status }]);
|
||||
expect(result.status, result.error).toBe(0);
|
||||
expect(result.matrix).toEqual({ include: [{ pr: 42, head }] });
|
||||
expect(result.published).toHaveLength(1);
|
||||
|
|
@ -719,14 +688,7 @@ describe("scheduled reconciliation", () => {
|
|||
);
|
||||
|
||||
it("selects a head with no ci-gate status", () => {
|
||||
const result = evaluate({
|
||||
eventName: "schedule",
|
||||
responses: {
|
||||
[`${prefix}/commits/${head}/statuses?per_page=100&page=1`]: {
|
||||
body: [{ context: "unrelated/status", state: "success" }],
|
||||
},
|
||||
},
|
||||
});
|
||||
const result = reconcileStatuses([{ context: "unrelated/status", state: "success" }]);
|
||||
expect(result).toMatchObject({ status: 0, matrix: { include: [{ pr: 42, head }] } });
|
||||
expect(result.published).toHaveLength(1);
|
||||
});
|
||||
|
|
@ -965,7 +927,7 @@ describe("scheduled reconciliation", () => {
|
|||
},
|
||||
);
|
||||
|
||||
it.each([99, 100, 101])("selects at most 100 of %s stale candidates, oldest first", (count) => {
|
||||
it.each([100, 101])("selects at most 100 of %s stale candidates, oldest first", (count) => {
|
||||
const candidates = Array.from({ length: count }, (_, index) => ({
|
||||
...completedRun,
|
||||
id: index + 1,
|
||||
|
|
|
|||
|
|
@ -53,7 +53,6 @@ if (args[0] === "backup" && args[1] === "create") {
|
|||
execFileSync("tar", ["-xzf", archive, "-C", target]);
|
||||
if (controls.mode === "lost-transcript") fs.unlinkSync(path.join(state, controls.transcriptRelative));
|
||||
const db = new DatabaseSync(path.join(state, controls.agentRelative));
|
||||
if (controls.mode === "missing-event") db.exec("DELETE FROM transcript_events WHERE seq = 2");
|
||||
if (controls.mode === "changed-session") db.exec("UPDATE session_nodes SET current_session_id = 'different-session'");
|
||||
if (controls.mode === "changed-payload") db.exec("UPDATE transcript_events SET event_json = '{}' WHERE seq = 1");
|
||||
if (controls.mode === "changed-schema") db.exec("PRAGMA user_version = 21");
|
||||
|
|
@ -163,6 +162,13 @@ function fixture(withTranscript = false) {
|
|||
const beforeFile = join(artifactRoot, "schema-before.json");
|
||||
const afterFile = join(artifactRoot, "schema-after.json");
|
||||
const resultFile = join(artifactRoot, "backup-rollback.json");
|
||||
const fileEvidence = (kind: string, relative: string) => ({
|
||||
kind,
|
||||
relative,
|
||||
sha256: createHash("sha256")
|
||||
.update(readFileSync(join(state, relative)))
|
||||
.digest("hex"),
|
||||
});
|
||||
const schema = {
|
||||
baselineVersion: "2026.9.4",
|
||||
candidateVersion: "2026.9.5",
|
||||
|
|
@ -173,36 +179,18 @@ function fixture(withTranscript = false) {
|
|||
{ kind: "agent", relative: agentRelative, userVersion: 19, contentVersion: 19 },
|
||||
],
|
||||
agents: [
|
||||
{ agentId: "main", databaseRelative: agentRelative, files: [] },
|
||||
{
|
||||
agentId: "main",
|
||||
databaseRelative: agentRelative,
|
||||
files: withTranscript ? [fileEvidence("transcript", transcriptRelative)] : [],
|
||||
},
|
||||
{
|
||||
agentId: "ops",
|
||||
databaseRelative: "agents/ops/agent/openclaw-agent.sqlite",
|
||||
files: [
|
||||
{
|
||||
kind: "legacy-store",
|
||||
relative: legacyRelative,
|
||||
sha256: createHash("sha256")
|
||||
.update(readFileSync(join(state, legacyRelative)))
|
||||
.digest("hex"),
|
||||
},
|
||||
],
|
||||
files: [fileEvidence("legacy-store", legacyRelative)],
|
||||
},
|
||||
],
|
||||
};
|
||||
if (withTranscript) {
|
||||
const files = schema.agents[0]!.files as Array<{
|
||||
kind: string;
|
||||
relative: string;
|
||||
sha256: string;
|
||||
}>;
|
||||
files.push({
|
||||
kind: "transcript",
|
||||
relative: transcriptRelative,
|
||||
sha256: createHash("sha256")
|
||||
.update(readFileSync(join(state, transcriptRelative)))
|
||||
.digest("hex"),
|
||||
});
|
||||
}
|
||||
writeJson(beforeFile, schema);
|
||||
writeJson(afterFile, {
|
||||
...schema,
|
||||
|
|
@ -311,7 +299,6 @@ describe("published backup rollback proof", () => {
|
|||
|
||||
it.each([
|
||||
"zero-omission",
|
||||
"missing-history",
|
||||
"changed-history",
|
||||
"unqualified-path",
|
||||
"unqualified-kind",
|
||||
|
|
@ -321,12 +308,10 @@ describe("published backup rollback proof", () => {
|
|||
if (mode === "zero-omission") {
|
||||
f.mode(mode);
|
||||
}
|
||||
if (mode === "missing-history" || mode === "changed-history") {
|
||||
if (mode === "changed-history") {
|
||||
const db = new DatabaseSync(join(f.state, "agents/main/agent/openclaw-agent.sqlite"));
|
||||
db.exec(
|
||||
mode === "missing-history"
|
||||
? "DELETE FROM transcript_events WHERE session_id = 'upgrade-restored-index-history'"
|
||||
: "UPDATE transcript_events SET event_json = '{}' WHERE session_id = 'upgrade-restored-index-history' AND seq = 1",
|
||||
"UPDATE transcript_events SET event_json = '{}' WHERE session_id = 'upgrade-restored-index-history' AND seq = 1",
|
||||
);
|
||||
db.close();
|
||||
}
|
||||
|
|
@ -368,7 +353,6 @@ describe("published backup rollback proof", () => {
|
|||
});
|
||||
|
||||
it.each([
|
||||
["missing-event", "restored baseline inventory differs"],
|
||||
["changed-session", "restored baseline inventory differs"],
|
||||
["changed-payload", "restored baseline inventory differs"],
|
||||
["changed-schema", "restored baseline inventory differs"],
|
||||
|
|
@ -422,20 +406,18 @@ describe("published backup rollback proof", () => {
|
|||
expect(result.stderr).toContain("inventory path escaped its root");
|
||||
});
|
||||
|
||||
it.each(["2026.9.3", "2026.9.4-beta.1"])(
|
||||
"records %s as not applicable without launching a baseline runtime",
|
||||
(version) => {
|
||||
const f = fixture();
|
||||
const result = f.run("eligibility", version, f.resultFile);
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
expect(readJson(f.resultFile)).toMatchObject({
|
||||
status: "not-applicable",
|
||||
baselineVersion: version,
|
||||
});
|
||||
expect(f.verify().status).toBe(0);
|
||||
expect(existsSync(f.events)).toBe(false);
|
||||
},
|
||||
);
|
||||
it("records pre-9.4 baselines as not applicable without launching a baseline runtime", () => {
|
||||
const version = "2026.9.4-beta.1";
|
||||
const f = fixture();
|
||||
const result = f.run("eligibility", version, f.resultFile);
|
||||
expect(result.status, result.stderr).toBe(0);
|
||||
expect(readJson(f.resultFile)).toMatchObject({
|
||||
status: "not-applicable",
|
||||
baselineVersion: version,
|
||||
});
|
||||
expect(f.verify().status).toBe(0);
|
||||
expect(existsSync(f.events)).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe.skipIf(process.platform === "win32")("survivor rollback ordering", () => {
|
||||
|
|
|
|||
|
|
@ -1,14 +1,16 @@
|
|||
import { spawnSync } from "node:child_process";
|
||||
import { mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { mkdirSync, readFileSync, writeFileSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { afterEach, describe, expect, it } from "vitest";
|
||||
import { useAutoCleanupTempDirTracker } from "../helpers/temp-dir.js";
|
||||
|
||||
const tempDirs = useAutoCleanupTempDirTracker(afterEach);
|
||||
|
||||
function runFixture(
|
||||
failure: "version" | "runtime" | "install" | "launch",
|
||||
overrides: NodeJS.ProcessEnv = {},
|
||||
) {
|
||||
const root = mkdtempSync(path.join(tmpdir(), "survivor-precheck-"));
|
||||
const root = tempDirs.make("survivor-precheck-");
|
||||
const bin = path.join(root, "bin");
|
||||
const evidence = path.join(root, "evidence");
|
||||
const installs = path.join(root, "installs.jsonl");
|
||||
|
|
@ -54,7 +56,12 @@ fs.writeFileSync(path.join(prefix, "bin", "openclaw"), '#!/usr/bin/env node\\n'
|
|||
},
|
||||
},
|
||||
);
|
||||
return { root, result, evidence, installs };
|
||||
return {
|
||||
result,
|
||||
evidence,
|
||||
installs,
|
||||
report: JSON.parse(readFileSync(path.join(evidence, "summary.json"), "utf8")),
|
||||
};
|
||||
}
|
||||
|
||||
describe("published baseline startup admission", () => {
|
||||
|
|
@ -62,50 +69,43 @@ describe("published baseline startup admission", () => {
|
|||
"skips unusable %s baselines with evidence before scheduling scenarios",
|
||||
(failure) => {
|
||||
const fixture = runFixture(failure);
|
||||
try {
|
||||
expect(fixture.result.status, fixture.result.stderr).toBe(0);
|
||||
const groups = JSON.parse(fixture.result.stdout);
|
||||
expect(groups.map((group: { label: string }) => group.label)).toEqual([
|
||||
"published-upgrade-survivor-2026.8.2",
|
||||
"onboard",
|
||||
expect(fixture.result.status, fixture.result.stderr).toBe(0);
|
||||
const groups = JSON.parse(fixture.result.stdout);
|
||||
expect(groups.map((group: { label: string }) => group.label)).toEqual([
|
||||
"published-upgrade-survivor-2026.8.2",
|
||||
"onboard",
|
||||
]);
|
||||
expect(fixture.report.baselines).toEqual([
|
||||
expect.objectContaining({
|
||||
baseline: "openclaw@2026.8.1",
|
||||
status: "skipped",
|
||||
reason: expect.stringContaining("unusable published baseline"),
|
||||
error: expect.stringContaining("Cannot find package fixture-runtime"),
|
||||
scenarios: ["legacy-operator-state", "base"],
|
||||
}),
|
||||
expect.objectContaining({ baseline: "openclaw@2026.8.2", status: "usable" }),
|
||||
]);
|
||||
const installs = readFileSync(fixture.installs, "utf8")
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => JSON.parse(line));
|
||||
expect(installs).toHaveLength(2);
|
||||
for (const install of installs) {
|
||||
expect(install.args).toEqual([
|
||||
"install",
|
||||
"-g",
|
||||
"--prefix",
|
||||
install.prefix,
|
||||
expect.stringMatching(/^openclaw@/),
|
||||
"--no-fund",
|
||||
"--no-audit",
|
||||
]);
|
||||
const report = JSON.parse(
|
||||
readFileSync(path.join(fixture.evidence, "summary.json"), "utf8"),
|
||||
);
|
||||
expect(report.baselines).toEqual([
|
||||
expect.objectContaining({
|
||||
baseline: "openclaw@2026.8.1",
|
||||
status: "skipped",
|
||||
reason: expect.stringContaining("unusable published baseline"),
|
||||
error: expect.stringContaining("Cannot find package fixture-runtime"),
|
||||
scenarios: ["legacy-operator-state", "base"],
|
||||
}),
|
||||
expect.objectContaining({ baseline: "openclaw@2026.8.2", status: "usable" }),
|
||||
]);
|
||||
const installs = readFileSync(fixture.installs, "utf8")
|
||||
.trim()
|
||||
.split("\n")
|
||||
.map((line) => JSON.parse(line));
|
||||
expect(installs).toHaveLength(2);
|
||||
for (const install of installs) {
|
||||
expect(install.args).toEqual([
|
||||
"install",
|
||||
"-g",
|
||||
"--prefix",
|
||||
install.prefix,
|
||||
expect.stringMatching(/^openclaw@/),
|
||||
"--no-fund",
|
||||
"--no-audit",
|
||||
]);
|
||||
expect(() => readFileSync(path.join(install.prefix, "bin", "openclaw"))).toThrow();
|
||||
}
|
||||
const summary = readFileSync(path.join(fixture.evidence, "summary.md"), "utf8");
|
||||
expect(summary).toContain("skipped");
|
||||
expect(summary).toContain("Cannot find package fixture-runtime");
|
||||
expect(summary).not.toContain("passed");
|
||||
} finally {
|
||||
rmSync(fixture.root, { recursive: true, force: true });
|
||||
expect(() => readFileSync(path.join(install.prefix, "bin", "openclaw"))).toThrow();
|
||||
}
|
||||
const summary = readFileSync(path.join(fixture.evidence, "summary.md"), "utf8");
|
||||
expect(summary).toContain("skipped");
|
||||
expect(summary).toContain("Cannot find package fixture-runtime");
|
||||
expect(summary).not.toContain("passed");
|
||||
},
|
||||
);
|
||||
|
||||
|
|
@ -118,20 +118,15 @@ describe("published baseline startup admission", () => {
|
|||
GROUP_SIZE: "2",
|
||||
OPENCLAW_UPGRADE_SURVIVOR_BASELINE_SPECS: "",
|
||||
});
|
||||
try {
|
||||
expect(fixture.result.status, fixture.result.stderr).toBe(0);
|
||||
expect(
|
||||
JSON.parse(fixture.result.stdout).map(
|
||||
(group: { docker_lanes: string }) => group.docker_lanes,
|
||||
),
|
||||
).toEqual(expected);
|
||||
const report = JSON.parse(readFileSync(path.join(fixture.evidence, "summary.json"), "utf8"));
|
||||
expect(report.baselines).toEqual([
|
||||
expect.objectContaining({ baseline: "openclaw@2026.8.1", status: "skipped" }),
|
||||
]);
|
||||
} finally {
|
||||
rmSync(fixture.root, { recursive: true, force: true });
|
||||
}
|
||||
expect(fixture.result.status, fixture.result.stderr).toBe(0);
|
||||
expect(
|
||||
JSON.parse(fixture.result.stdout).map(
|
||||
(group: { docker_lanes: string }) => group.docker_lanes,
|
||||
),
|
||||
).toEqual(expected);
|
||||
expect(fixture.report.baselines).toEqual([
|
||||
expect.objectContaining({ baseline: "openclaw@2026.8.1", status: "skipped" }),
|
||||
]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
|
|
@ -141,18 +136,11 @@ describe("published baseline startup admission", () => {
|
|||
"fails closed on $failure errors, preserving diagnostics instead of skipping coverage",
|
||||
({ failure, error }) => {
|
||||
const fixture = runFixture(failure);
|
||||
try {
|
||||
expect(fixture.result.status).not.toBe(0);
|
||||
const report = JSON.parse(
|
||||
readFileSync(path.join(fixture.evidence, "summary.json"), "utf8"),
|
||||
);
|
||||
expect(report.baselines[0]).toMatchObject({
|
||||
status: "failed",
|
||||
error: expect.stringContaining(error),
|
||||
});
|
||||
} finally {
|
||||
rmSync(fixture.root, { recursive: true, force: true });
|
||||
}
|
||||
expect(fixture.result.status).not.toBe(0);
|
||||
expect(fixture.report.baselines[0]).toMatchObject({
|
||||
status: "failed",
|
||||
error: expect.stringContaining(error),
|
||||
});
|
||||
},
|
||||
);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -134,7 +134,6 @@ it.each(["2026.9.4", "2026.9.6"])(
|
|||
it.each([
|
||||
{ source: "native", damage: "none" },
|
||||
{ source: "legacy", damage: "none" },
|
||||
{ source: "updater", damage: "none" },
|
||||
{ source: "native", damage: "content" },
|
||||
{ source: "legacy", damage: "missing-filter" },
|
||||
{ source: "native", damage: "offset" },
|
||||
|
|
@ -155,8 +154,7 @@ it.each([
|
|||
})),
|
||||
}),
|
||||
);
|
||||
const contract =
|
||||
source === "updater" ? "published-updater-import-preserved" : "candidate-doctor-import";
|
||||
const contract = "candidate-doctor-import";
|
||||
if (source !== "native") {
|
||||
fs.writeFileSync(
|
||||
path.join(root, "legacy-operator-cron-history.json"),
|
||||
|
|
|
|||
|
|
@ -61,18 +61,14 @@ describe("upgrade survivor live model selection", () => {
|
|||
).toMatchObject({ overridesLiveOpenai: true, models: [{ provider: "google" }] });
|
||||
});
|
||||
|
||||
it.each([
|
||||
["openai/gpt-5.5", "OPENAI_API_KEY"],
|
||||
["anthropic/claude-opus-5", "ANTHROPIC_API_KEY"],
|
||||
["google/gemini-3.1-pro-preview", "GEMINI_API_KEY"],
|
||||
])("fails clearly when %s has no key", (model, keyEnv) => {
|
||||
it("names the missing provider key", () => {
|
||||
expect(() =>
|
||||
resolveLiveModels({
|
||||
...keys,
|
||||
[keyEnv]: " ",
|
||||
OPENCLAW_UPGRADE_SURVIVOR_LIVE_MODELS: model,
|
||||
OPENAI_API_KEY: " ",
|
||||
OPENCLAW_UPGRADE_SURVIVOR_LIVE_MODELS: "openai/gpt-5.5",
|
||||
}),
|
||||
).toThrow(`Live model ${model} requires ${keyEnv}`);
|
||||
).toThrow("Live model openai/gpt-5.5 requires OPENAI_API_KEY");
|
||||
});
|
||||
|
||||
it.each([" \t\n", "gpt-5.5", "unknown/model", "openai/gpt-5.5 openai/gpt-5.5"])(
|
||||
|
|
|
|||
|
|
@ -293,7 +293,6 @@ it("keeps policy log limits and symlink protections on successful publication",
|
|||
it.each([
|
||||
{ candidateVersion: "2026.9.5" },
|
||||
{ activePlugins: ["device-pair"] },
|
||||
{ candidateEnabledPlugins: ["memory-core", "telegram", "unrelated"] },
|
||||
{ candidateEnabledPlugins: ["telegram"] },
|
||||
{ baselineEnabledPlugins: [] },
|
||||
{ hookUnauthorizedStatus: 200 },
|
||||
|
|
@ -351,18 +350,43 @@ it.each(["failed", "passed"] as const)(
|
|||
},
|
||||
);
|
||||
|
||||
function integrityLog(f: ReturnType<typeof fixture>, telemetry: Record<string, unknown>) {
|
||||
function integrityFixture(
|
||||
identity: Record<string, unknown> = {},
|
||||
telemetry: Record<string, unknown> = {},
|
||||
) {
|
||||
const f = fixture();
|
||||
write(path.join(f.artifacts, "diagnostics/process-42-started.json"), {
|
||||
event: "started",
|
||||
role: "update",
|
||||
packageVersion: "2026.9.4",
|
||||
pid: 42,
|
||||
parentPid: 1,
|
||||
timeOriginUnixMs: 100,
|
||||
...identity,
|
||||
});
|
||||
const observation = {
|
||||
readerId: "42:1",
|
||||
timeOriginUnixMs: 100,
|
||||
event: "reader-settled",
|
||||
phase: "retained",
|
||||
budgetMs: 30000,
|
||||
elapsedMs: 30002,
|
||||
outcome: "timed-out",
|
||||
pendingIo: 0,
|
||||
...telemetry,
|
||||
};
|
||||
fs.writeFileSync(
|
||||
path.join(f.root, "openclaw-upgrade-survivor", "gateway.jsonl"),
|
||||
JSON.stringify({
|
||||
0: JSON.stringify({ subsystem: "update/package-integrity" }),
|
||||
1: telemetry,
|
||||
2: telemetry.event,
|
||||
message: telemetry.event,
|
||||
1: observation,
|
||||
2: observation.event,
|
||||
message: observation.event,
|
||||
_meta: { logLevelName: "DEBUG", path: privateBody },
|
||||
privateBody,
|
||||
}) + "\n",
|
||||
);
|
||||
return f;
|
||||
}
|
||||
|
||||
it("captures only the actual baseline updater's released integrity envelope and preserves failure", () => {
|
||||
|
|
@ -439,35 +463,12 @@ process.exit(1);`,
|
|||
|
||||
it.each([
|
||||
{ name: "different PID", identity: { pid: 43 } },
|
||||
{ name: "different process origin", identity: { timeOriginUnixMs: 101 } },
|
||||
{ name: "Doctor", identity: { role: "doctor" } },
|
||||
{ name: "post-core", identity: { role: "post-core" } },
|
||||
{ name: "different package", identity: { packageVersion: "2026.9.5" } },
|
||||
{ name: "non-numeric budget", telemetry: { budgetMs: "30000" } },
|
||||
{ name: "missing settled outcome", telemetry: { outcome: undefined } },
|
||||
{ name: "negative pending I/O", telemetry: { pendingIo: -1 } },
|
||||
])("omits integrity evidence from $name", ({ identity, telemetry }) => {
|
||||
const f = fixture();
|
||||
write(path.join(f.artifacts, "diagnostics/process-42-started.json"), {
|
||||
event: "started",
|
||||
role: "update",
|
||||
packageVersion: "2026.9.4",
|
||||
pid: 42,
|
||||
parentPid: 1,
|
||||
timeOriginUnixMs: 100,
|
||||
...identity,
|
||||
});
|
||||
integrityLog(f, {
|
||||
readerId: "42:1",
|
||||
timeOriginUnixMs: 100,
|
||||
event: "reader-settled",
|
||||
phase: "retained",
|
||||
budgetMs: 30000,
|
||||
elapsedMs: 30002,
|
||||
outcome: "timed-out",
|
||||
pendingIo: 0,
|
||||
...telemetry,
|
||||
});
|
||||
const f = integrityFixture(identity, telemetry);
|
||||
const report = capture(f);
|
||||
expect(report.exitStatus).toBe(1);
|
||||
expect(report.packageIntegrity).toEqual({ availability: "unavailable" });
|
||||
|
|
@ -477,25 +478,7 @@ it.each([
|
|||
it.each(["input", "output", "entries", "symlink", "directory-symlink", "malformed"] as const)(
|
||||
"omits the whole integrity diagnostic at the %s boundary",
|
||||
(boundary) => {
|
||||
const f = fixture();
|
||||
write(path.join(f.artifacts, "diagnostics/process-42-started.json"), {
|
||||
event: "started",
|
||||
role: "update",
|
||||
packageVersion: "2026.9.4",
|
||||
pid: 42,
|
||||
parentPid: 1,
|
||||
timeOriginUnixMs: 100,
|
||||
});
|
||||
integrityLog(f, {
|
||||
readerId: "42:1",
|
||||
timeOriginUnixMs: 100,
|
||||
event: "reader-settled",
|
||||
phase: "transaction",
|
||||
budgetMs: 30000,
|
||||
elapsedMs: 30002,
|
||||
outcome: "timed-out",
|
||||
pendingIo: 1,
|
||||
});
|
||||
const f = integrityFixture({}, { phase: "transaction", pendingIo: 1 });
|
||||
const log = path.join(f.root, "openclaw-upgrade-survivor", "gateway.jsonl");
|
||||
const line = fs.readFileSync(log, "utf8");
|
||||
if (boundary === "symlink") {
|
||||
|
|
|
|||
|
|
@ -83,14 +83,11 @@ describe("isolated Vitest admission", () => {
|
|||
args: ["run", "-t"],
|
||||
});
|
||||
});
|
||||
it.each(["latest", "repo:tag", "sha256:1234", "", "--network=host"])(
|
||||
"refuses ambiguous image %s",
|
||||
(value) => {
|
||||
expect(() => parseIsolatedVitestArgs(["--isolated-image", value, "run", file])).toThrow(
|
||||
"full local sha256",
|
||||
);
|
||||
},
|
||||
);
|
||||
it.each(["repo:tag", "sha256:1234", ""])("refuses ambiguous image %s", (value) => {
|
||||
expect(() => parseIsolatedVitestArgs(["--isolated-image", value, "run", file])).toThrow(
|
||||
"full local sha256",
|
||||
);
|
||||
});
|
||||
it("refuses duplicate image selectors", () => {
|
||||
expect(() =>
|
||||
parseIsolatedVitestArgs(["--isolated-image", image, "--isolated-image", image]),
|
||||
|
|
@ -375,7 +372,7 @@ function lifecycle(
|
|||
}
|
||||
|
||||
describe("isolated container lifecycle", () => {
|
||||
it.each([0, 1, 7])(
|
||||
it.each([0, 7])(
|
||||
"preserves test exit %s only after wait, remove and confirmed absence",
|
||||
async (exit) => {
|
||||
const fixture = lifecycle({ exit });
|
||||
|
|
@ -415,15 +412,12 @@ describe("isolated container lifecycle", () => {
|
|||
expect(onAbsent).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
it("reconciles a failed create without starting or falling back", async () => {
|
||||
const fixture = lifecycle({ failCreate: true });
|
||||
await expect(fixture.run()).rejects.toThrow("create failed");
|
||||
expect(fixture.calls.some((args) => args[0] === "start")).toBe(false);
|
||||
expect(fixture.onAbsent).toHaveBeenCalledOnce();
|
||||
});
|
||||
it("does not start a container whose inspection fails", async () => {
|
||||
const fixture = lifecycle({ failVerify: true });
|
||||
await expect(fixture.run()).rejects.toThrow("unexpected mounts");
|
||||
it.each([
|
||||
{ options: { failCreate: true }, error: "create failed" },
|
||||
{ options: { failVerify: true }, error: "unexpected mounts" },
|
||||
])("reconciles $error without starting the container", async ({ options, error }) => {
|
||||
const fixture = lifecycle(options);
|
||||
await expect(fixture.run()).rejects.toThrow(error);
|
||||
expect(fixture.calls.some((args) => args[0] === "start")).toBe(false);
|
||||
expect(fixture.onAbsent).toHaveBeenCalledOnce();
|
||||
});
|
||||
|
|
|
|||
|
|
@ -86,15 +86,7 @@ const discordCapture = "test/e2e/gateway-transcripts-discord-capture.e2e.test.ts
|
|||
const e2eConfig = "test/vitest/vitest.e2e.config.ts";
|
||||
|
||||
it.for([
|
||||
{ name: "worker", args: [coreWorker], prepare: true },
|
||||
{ name: "absolute worker", args: [path.resolve(coreWorker)], prepare: true },
|
||||
{ name: "line selection", args: [`${coreWorker}:12`], prepare: true },
|
||||
{ name: "ordinary infra", args: ["src/infra/node-sqlite.test.ts"], prepare: false },
|
||||
{ name: "excluded worker", args: [coreWorker, "--exclude", coreWorker], prepare: false },
|
||||
{ name: "excluded glob", args: [coreWorker, "--exclude=src/infra/**"], prepare: false },
|
||||
{ name: "empty include", args: [], include: [], prepare: false },
|
||||
{ name: "worker include", args: [], include: [coreWorker], prepare: true },
|
||||
{ name: "nonmatching include", args: [coreWorker], include: ["test/**"], prepare: false },
|
||||
{ name: "root config", config: "vitest.config.ts", args: [coreWorker], prepare: true },
|
||||
{ name: "custom config", config: "custom.config.ts", args: [coreWorker], prepare: false },
|
||||
{ name: "full channels", config: channelsConfig, args: [], prepare: true },
|
||||
|
|
@ -105,12 +97,12 @@ it.for([
|
|||
prepare: false,
|
||||
},
|
||||
{ name: "empty channels", config: channelsConfig, args: [], include: [], prepare: false },
|
||||
...[agentVitestProjectOwners.core, agentVitestProjectOwners.all].map((owner) => ({
|
||||
name: `code-mode ${owner.name}`,
|
||||
config: owner.config,
|
||||
{
|
||||
name: "code-mode all agents",
|
||||
config: agentVitestProjectOwners.all.config,
|
||||
args: [codeModeWorker],
|
||||
prepare: true,
|
||||
})),
|
||||
},
|
||||
{
|
||||
name: "code-mode full agentic config",
|
||||
config: "test/vitest/vitest.full-agentic.config.ts",
|
||||
|
|
@ -123,13 +115,6 @@ it.for([
|
|||
args: [codeModeWorker, "--exclude", path.basename(codeModeWorker)],
|
||||
prepare: false,
|
||||
},
|
||||
{
|
||||
name: "code-mode include",
|
||||
config: agentsCoreConfig,
|
||||
args: [],
|
||||
include: [codeModeWorker],
|
||||
prepare: true,
|
||||
},
|
||||
{
|
||||
name: "code-mode omitted by include",
|
||||
config: agentsCoreConfig,
|
||||
|
|
@ -137,12 +122,6 @@ it.for([
|
|||
include: ["src/agents/code-mode.test.ts"],
|
||||
prepare: false,
|
||||
},
|
||||
{
|
||||
name: "code-mode non-owning config",
|
||||
config: infraConfig,
|
||||
args: [codeModeWorker],
|
||||
prepare: false,
|
||||
},
|
||||
{
|
||||
name: "code-mode selection leaves unrelated agents lazy",
|
||||
config: agentsCoreConfig,
|
||||
|
|
@ -163,7 +142,6 @@ it.runIf(process.platform !== "win32").for([
|
|||
(route.startsWith("contracts-")
|
||||
? ["ready", "excluded"]
|
||||
: [
|
||||
"ready",
|
||||
"code-mode",
|
||||
"capture",
|
||||
"failure",
|
||||
|
|
|
|||
|
|
@ -1,10 +1,8 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { matchGitHubItemUrl } from "./github-link-eligibility.ts";
|
||||
import { parseGitHubLinkTarget } from "./github-link-target.ts";
|
||||
|
||||
describe("GitHub issue and pull-request preview targets", () => {
|
||||
it.each([
|
||||
["https://github.com/acme/project/issues/42", "issue"],
|
||||
["https://github.com/acme/project/issues/42/#issuecomment-7", "issue"],
|
||||
["https://github.com/acme/project/pull/42", "pull"],
|
||||
["https://github.com/acme/project/pull/42/files#diff-example", "pull"],
|
||||
|
|
@ -14,7 +12,6 @@ describe("GitHub issue and pull-request preview targets", () => {
|
|||
["HTTPS://GITHUB.COM:443/acme/project/pull/42", "pull"],
|
||||
["https://github.com/%61cme/project/pull/42", "pull"],
|
||||
])("preserves resource identity and destination for %s", (href, kind) => {
|
||||
expect(matchGitHubItemUrl(new URL(href))).not.toBeNull();
|
||||
expect(parseGitHubLinkTarget(href)).toEqual({
|
||||
kind,
|
||||
owner: "acme",
|
||||
|
|
@ -25,18 +22,9 @@ describe("GitHub issue and pull-request preview targets", () => {
|
|||
});
|
||||
|
||||
it.each([
|
||||
"https://github.com/login",
|
||||
"https://github.com/login?return_to=https://github.com/acme/project/pull/42",
|
||||
"https://github.com/login?return_to=%2Facme%2Fproject%2Fissues%2F42",
|
||||
"https://github.com/login/oauth/authorize?client_id=example-client",
|
||||
"https://github.com/session",
|
||||
"https://github.com/settings/connections/applications/example",
|
||||
"https://github.com/",
|
||||
"https://github.com/acme",
|
||||
"https://github.com/acme/project",
|
||||
"https://github.com/acme/project/commit/abcdef0123456789",
|
||||
"https://example.com/?redirect=https://github.com/acme/project/pull/42",
|
||||
"https://example.com/https://github.com/acme/project/pull/42",
|
||||
"https://github.com.example.com/acme/project/pull/42",
|
||||
"https://example@github.com/acme/project/pull/42",
|
||||
"http://github.com/acme/project/pull/42",
|
||||
|
|
@ -48,15 +36,11 @@ describe("GitHub issue and pull-request preview targets", () => {
|
|||
"https://github.com/acme/" + "r".repeat(101) + "/pull/42",
|
||||
"https://github.com/acme/project.git/pull/42",
|
||||
"https://github.com/acme/project.atom/pull/42",
|
||||
"https://github.com/acme/project/pull/42/login",
|
||||
"https://github.com/acme/project/issues/42/files",
|
||||
"https://github.com/acme/project/pull/42/files/extra",
|
||||
"https://github.com//acme/project/pull/42",
|
||||
"https://github.com/acme%2Fother/project/pull/42",
|
||||
"https://github.com/%20acme/project/pull/42",
|
||||
"https://github.com/acme/project%2Fother/pull/42",
|
||||
])("does not infer an issue or PR from %s", (href) => {
|
||||
expect(matchGitHubItemUrl(new URL(href))).toBeNull();
|
||||
expect(parseGitHubLinkTarget(href)).toBeNull();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ afterEach(() => {
|
|||
});
|
||||
|
||||
describe("image lightbox gallery resource lifecycle", () => {
|
||||
it.each(["close", "reset", "evict"] as const)(
|
||||
it.each(["reset", "evict"] as const)(
|
||||
"releases a late full-resolution image after %s without replacing newer intent",
|
||||
async (action) => {
|
||||
const initial = imageItem("preview");
|
||||
|
|
@ -49,17 +49,13 @@ describe("image lightbox gallery resource lifecycle", () => {
|
|||
|
||||
if (action === "reset") {
|
||||
controller.reset(undefined, replacement);
|
||||
} else if (action === "evict") {
|
||||
expect(await controller.move(1)).toBe(true);
|
||||
expect(await controller.move(1)).toBe(true);
|
||||
} else {
|
||||
controller.dispose();
|
||||
expect(await controller.move(1)).toBe(true);
|
||||
expect(await controller.move(1)).toBe(true);
|
||||
}
|
||||
pending.resolve(original);
|
||||
await vi.waitFor(() => expect(original.release).toHaveBeenCalledOnce());
|
||||
expect(controller.current).toBe(
|
||||
action === "close" ? undefined : action === "evict" ? beyond : replacement,
|
||||
);
|
||||
expect(controller.current).toBe(action === "evict" ? beyond : replacement);
|
||||
controller.dispose();
|
||||
await Promise.resolve();
|
||||
expect(original.release).toHaveBeenCalledOnce();
|
||||
|
|
@ -117,39 +113,32 @@ describe("image lightbox gallery resource lifecycle", () => {
|
|||
},
|
||||
);
|
||||
|
||||
it.each(["close", "reset"] as const)(
|
||||
"releases a late image once after %s without replacing the current selection",
|
||||
async (action) => {
|
||||
const initial = imageItem("initial");
|
||||
const late = imageItem("late");
|
||||
const replacement = imageItem("replacement");
|
||||
const pending = createDeferred<ImageLightboxItem | null>();
|
||||
const load = vi.fn(() => pending.promise);
|
||||
controller.reset({ index: 0, items: [async () => initial, load] }, initial);
|
||||
const moving = controller.move(1);
|
||||
await vi.waitFor(() => expect(load).toHaveBeenCalledOnce());
|
||||
expect(controller.current).toBe(initial);
|
||||
expect(controller.busy).toBe(true);
|
||||
it("releases a late image once after reset without replacing the current selection", async () => {
|
||||
const initial = imageItem("initial");
|
||||
const late = imageItem("late");
|
||||
const replacement = imageItem("replacement");
|
||||
const pending = createDeferred<ImageLightboxItem | null>();
|
||||
const load = vi.fn(() => pending.promise);
|
||||
controller.reset({ index: 0, items: [async () => initial, load] }, initial);
|
||||
const moving = controller.move(1);
|
||||
await vi.waitFor(() => expect(load).toHaveBeenCalledOnce());
|
||||
expect(controller.current).toBe(initial);
|
||||
expect(controller.busy).toBe(true);
|
||||
|
||||
if (action === "reset") {
|
||||
controller.reset(undefined, replacement);
|
||||
} else {
|
||||
controller.dispose();
|
||||
}
|
||||
pending.resolve(late);
|
||||
controller.reset(undefined, replacement);
|
||||
pending.resolve(late);
|
||||
|
||||
expect(await moving).toBe(false);
|
||||
await vi.waitFor(() => expect(late.release).toHaveBeenCalledOnce());
|
||||
expect(controller.current).toBe(action === "reset" ? replacement : undefined);
|
||||
expect(controller.busy).toBe(false);
|
||||
expect(controller.failed).toBe(false);
|
||||
controller.dispose();
|
||||
await Promise.resolve();
|
||||
expect(late.release).toHaveBeenCalledOnce();
|
||||
expect(initial.release).not.toHaveBeenCalled();
|
||||
expect(replacement.release).not.toHaveBeenCalled();
|
||||
},
|
||||
);
|
||||
expect(await moving).toBe(false);
|
||||
await vi.waitFor(() => expect(late.release).toHaveBeenCalledOnce());
|
||||
expect(controller.current).toBe(replacement);
|
||||
expect(controller.busy).toBe(false);
|
||||
expect(controller.failed).toBe(false);
|
||||
controller.dispose();
|
||||
await Promise.resolve();
|
||||
expect(late.release).toHaveBeenCalledOnce();
|
||||
expect(initial.release).not.toHaveBeenCalled();
|
||||
expect(replacement.release).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("keeps the current image after a failed neighbor load and retries on navigation", async () => {
|
||||
const initial = imageItem("initial");
|
||||
|
|
|
|||
|
|
@ -8,12 +8,12 @@ import { LinkReaderHovercardProvider } from "./link-reader-hovercard.ts";
|
|||
const ELEMENT_NAME = "test-link-reader-interaction-" + crypto.randomUUID();
|
||||
customElements.define(ELEMENT_NAME, class extends LinkReaderHovercardProvider {});
|
||||
const ISSUE_HREF = "https://github.com/openclaw/openclaw/issues/99815";
|
||||
function createLink(href: string, label = "Item") {
|
||||
function createLink(href: string) {
|
||||
const provider = document.createElement(ELEMENT_NAME) as LinkReaderHovercardProvider;
|
||||
provider.readers = [TEST_LINK_READER];
|
||||
const anchor = document.createElement("a");
|
||||
anchor.href = href;
|
||||
anchor.textContent = label;
|
||||
anchor.textContent = "Item";
|
||||
provider.append(anchor);
|
||||
document.body.append(provider);
|
||||
return { provider, anchor };
|
||||
|
|
@ -25,14 +25,11 @@ function issuePreviewResponse(overrides: Record<string, unknown> = {}) {
|
|||
? { url: "https://github.com/openclaw/openclaw/issues/" + overrides.number }
|
||||
: {}),
|
||||
...overrides,
|
||||
...(typeof overrides.comments !== "number"
|
||||
? {}
|
||||
: { metadata: [{ label: "Comments", value: String(overrides.comments) }] }),
|
||||
};
|
||||
}
|
||||
function createIssueLink(response = issuePreviewResponse()) {
|
||||
function createIssueLink() {
|
||||
const link = createLink(ISSUE_HREF);
|
||||
const request = vi.fn().mockResolvedValue(response);
|
||||
const request = vi.fn().mockResolvedValue(issuePreviewResponse());
|
||||
link.provider.client = { request } as unknown as GatewayBrowserClient;
|
||||
return { ...link, request };
|
||||
}
|
||||
|
|
@ -76,7 +73,7 @@ describe("generic preview portal lifecycle", () => {
|
|||
vi.useRealTimers();
|
||||
vi.restoreAllMocks();
|
||||
});
|
||||
it.each(["immediate rejection", "late rejection", "late success"])(
|
||||
it.each(["immediate rejection", "late rejection"])(
|
||||
"reopens an abandoned request without poisoning its replacement cache: %s",
|
||||
async (settlement) => {
|
||||
const abandoned = createDeferred<ReturnType<typeof issuePreviewResponse>>();
|
||||
|
|
@ -109,11 +106,7 @@ describe("generic preview portal lifecycle", () => {
|
|||
expect(request).toHaveBeenCalledTimes(2);
|
||||
expect(hovercard()?.textContent).toContain("Keep hover previews reachable");
|
||||
|
||||
if (settlement === "late success") {
|
||||
abandoned.resolve(issuePreviewResponse({ title: "Abandoned preview" }));
|
||||
} else {
|
||||
abandoned.reject(new Error("gateway request aborted"));
|
||||
}
|
||||
abandoned.reject(new Error("gateway request aborted"));
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(hovercard()?.textContent).toContain("Keep hover previews reachable");
|
||||
leave(anchor);
|
||||
|
|
@ -124,40 +117,6 @@ describe("generic preview portal lifecycle", () => {
|
|||
},
|
||||
);
|
||||
|
||||
it("keeps genuine request failures cached for 30 seconds before retrying on hover", async () => {
|
||||
const retry = createDeferred<ReturnType<typeof issuePreviewResponse>>();
|
||||
const request = vi
|
||||
.fn()
|
||||
.mockRejectedValueOnce(
|
||||
new GatewayRequestError({ code: "UNAVAILABLE", message: "GitHub preview unavailable" }),
|
||||
)
|
||||
.mockReturnValue(retry.promise);
|
||||
const { anchor, provider } = createLink(ISSUE_HREF);
|
||||
provider.client = { request } as unknown as GatewayBrowserClient;
|
||||
|
||||
await hover(anchor);
|
||||
expect(hovercard()?.textContent).toContain("GitHub preview unavailable");
|
||||
expect(anchor.getAttribute("aria-expanded")).toBe("true");
|
||||
anchor.focus();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(document.activeElement).toBe(anchor);
|
||||
expect(anchor.getAttribute("aria-controls")).toBe(hovercard()?.id);
|
||||
anchor.blur();
|
||||
leave(anchor);
|
||||
await vi.advanceTimersByTimeAsync(29_000);
|
||||
await hover(anchor);
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
expect(hovercard()?.textContent).toContain("GitHub preview unavailable");
|
||||
leave(anchor);
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await hover(anchor);
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
expect(hovercard()).toBeNull();
|
||||
retry.resolve(issuePreviewResponse());
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(hovercard()?.textContent).toContain("Keep hover previews reachable");
|
||||
});
|
||||
|
||||
it("shows a pending preview rejection without moving focus and keeps its original link reachable", async () => {
|
||||
const pending = createDeferred<unknown>();
|
||||
const { anchor, provider } = createLink(ISSUE_HREF);
|
||||
|
|
@ -215,22 +174,18 @@ describe("generic preview portal lifecycle", () => {
|
|||
},
|
||||
);
|
||||
|
||||
it.each(
|
||||
[
|
||||
"pointer leave",
|
||||
"focus leave",
|
||||
"Escape",
|
||||
"click",
|
||||
"route replacement",
|
||||
"href change",
|
||||
"agent change",
|
||||
"client change",
|
||||
"disconnect",
|
||||
].flatMap((dismissal) => [
|
||||
{ dismissal, settlement: "success" },
|
||||
{ dismissal, settlement: "failure" },
|
||||
]),
|
||||
)("does not mount a late $settlement after $dismissal", async ({ dismissal, settlement }) => {
|
||||
it.each([
|
||||
{ dismissal: "pointer leave", settlement: "success" },
|
||||
{ dismissal: "pointer leave", settlement: "failure" },
|
||||
{ dismissal: "focus leave", settlement: "success" },
|
||||
{ dismissal: "Escape", settlement: "success" },
|
||||
{ dismissal: "click", settlement: "success" },
|
||||
{ dismissal: "route replacement", settlement: "success" },
|
||||
{ dismissal: "href change", settlement: "success" },
|
||||
{ dismissal: "agent change", settlement: "success" },
|
||||
{ dismissal: "client change", settlement: "success" },
|
||||
{ dismissal: "disconnect", settlement: "success" },
|
||||
])("does not mount a late $settlement after $dismissal", async ({ dismissal, settlement }) => {
|
||||
const mountedCards = observeHovercardMounts();
|
||||
const pending = createDeferred<ReturnType<typeof issuePreviewResponse>>();
|
||||
const { anchor, provider } = createLink(ISSUE_HREF);
|
||||
|
|
@ -451,8 +406,6 @@ describe("generic preview portal lifecycle", () => {
|
|||
|
||||
it.each([
|
||||
["agent", "agent-a"],
|
||||
["client", "agent-a"],
|
||||
["agent", "agent-b"],
|
||||
["client", "agent-b"],
|
||||
])(
|
||||
"preserves the unchanged provider when a peer changes its %s from %s",
|
||||
|
|
@ -587,20 +540,6 @@ describe("generic preview portal lifecycle", () => {
|
|||
expect(hovercard()).toBeNull();
|
||||
});
|
||||
|
||||
it("renders issue comments and supports focus plus Escape", async () => {
|
||||
const { anchor } = createIssueLink(issuePreviewResponse({ comments: 4 }));
|
||||
|
||||
anchor.dispatchEvent(new FocusEvent("focusin", { bubbles: true, composed: true }));
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
|
||||
expect(hovercard()?.textContent).toContain("Comments: 4");
|
||||
expect(hovercard()?.textContent).toContain("Open");
|
||||
// Issues have no files-changed view, so their metric stays plain text.
|
||||
expect(hovercard()?.querySelector(".link-reader-hovercard__metric--files")).toBeNull();
|
||||
anchor.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", bubbles: true }));
|
||||
expect(hovercard()).toBeNull();
|
||||
});
|
||||
|
||||
it.each([false, true])("hands focus back at Tab edges (shiftKey=%s)", async (shiftKey) => {
|
||||
const { anchor } = createIssueLink();
|
||||
|
||||
|
|
@ -632,19 +571,6 @@ describe("generic preview portal lifecycle", () => {
|
|||
expect(hovercard()).toBeNull();
|
||||
});
|
||||
|
||||
it("closes on Escape from inside the card and returns focus to the link", async () => {
|
||||
const { anchor } = createIssueLink();
|
||||
|
||||
anchor.focus();
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
const title = titleLinkInCard();
|
||||
title?.focus();
|
||||
title?.dispatchEvent(new KeyboardEvent("keydown", { bubbles: true, key: "Escape" }));
|
||||
|
||||
expect(hovercard()).toBeNull();
|
||||
expect(document.activeElement).toBe(anchor);
|
||||
});
|
||||
|
||||
it("closes once focus leaves both the link and the card", async () => {
|
||||
const { anchor } = createIssueLink();
|
||||
const outside = document.createElement("button");
|
||||
|
|
@ -661,54 +587,6 @@ describe("generic preview portal lifecycle", () => {
|
|||
expect(anchor.hasAttribute("aria-expanded")).toBe(false);
|
||||
});
|
||||
|
||||
it("ignores unsupported GitHub links and reports failed supported previews", async () => {
|
||||
const request = vi
|
||||
.fn()
|
||||
.mockRejectedValue(new GatewayRequestError({ code: "UNAVAILABLE", message: "Not Found" }));
|
||||
const unsupportedLink = createLink("https://github.com/openclaw/openclaw", "repository");
|
||||
unsupportedLink.provider.client = { request } as unknown as GatewayBrowserClient;
|
||||
|
||||
await hover(unsupportedLink.anchor);
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
expect(document.querySelector(".link-reader-hovercard")).toBeNull();
|
||||
|
||||
const missingLink = createLink("https://github.com/openclaw/openclaw/issues/999999", "missing");
|
||||
missingLink.provider.client = { request } as unknown as GatewayBrowserClient;
|
||||
await hover(missingLink.anchor);
|
||||
expect(hovercard()?.textContent).toContain("Not Found");
|
||||
expect(request).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("discards cached and pending previews when the selected agent changes", async () => {
|
||||
const { anchor, provider } = createIssueLink();
|
||||
const request = vi.fn().mockResolvedValue(issuePreviewResponse());
|
||||
provider.client = { request } as unknown as GatewayBrowserClient;
|
||||
provider.agentId = "first-agent";
|
||||
await hover(anchor);
|
||||
|
||||
expect(request.mock.calls[0]?.[1]).toMatchObject({ agentId: "first-agent" });
|
||||
provider.agentId = "second-agent";
|
||||
expect(hovercard()).toBeNull();
|
||||
await hover(anchor);
|
||||
expect(request).toHaveBeenCalledTimes(2);
|
||||
expect(request.mock.calls[1]?.[1]).toMatchObject({ agentId: "second-agent" });
|
||||
|
||||
let resolvePending!: (value: unknown) => void;
|
||||
const nextRequest = vi.fn().mockImplementation(
|
||||
() =>
|
||||
new Promise((resolve) => {
|
||||
resolvePending = resolve;
|
||||
}),
|
||||
);
|
||||
provider.client = { request: nextRequest } as unknown as GatewayBrowserClient;
|
||||
expect(hovercard()).toBeNull();
|
||||
await hover(anchor);
|
||||
provider.agentId = "third-agent";
|
||||
resolvePending(issuePreviewResponse());
|
||||
await vi.advanceTimersByTimeAsync(0);
|
||||
expect(hovercard()).toBeNull();
|
||||
});
|
||||
|
||||
it("uses the latest dependencies assigned before its lazy definition finishes", async () => {
|
||||
const tag = `test-github-lazy-upgrade-${crypto.randomUUID()}`;
|
||||
const loaded = createDeferred<CustomElementConstructor>();
|
||||
|
|
@ -745,13 +623,9 @@ describe("generic preview portal lifecycle", () => {
|
|||
});
|
||||
|
||||
it.each([
|
||||
"http://github.com/openclaw/openclaw/issues/99815",
|
||||
"https://user:password@github.com/openclaw/openclaw/issues/99815",
|
||||
"https://github.com:8443/openclaw/openclaw/issues/99815",
|
||||
"https://github.com.example.com/openclaw/openclaw/issues/99815",
|
||||
"blob:https://github.com/issues/99815",
|
||||
"https://example.com/openclaw/openclaw/issues/99815",
|
||||
"javascript:alert(1)",
|
||||
])("does not preview an untrusted item URL: %s", async (href) => {
|
||||
const request = vi.fn();
|
||||
const { anchor, provider } = createLink(href);
|
||||
|
|
@ -763,20 +637,6 @@ describe("generic preview portal lifecycle", () => {
|
|||
expect(document.querySelector(".link-reader-hovercard")).toBeNull();
|
||||
});
|
||||
|
||||
it("leaves no popup state on the link when hover ends before opening", async () => {
|
||||
const request = vi.fn();
|
||||
const { anchor, provider } = createLink("https://github.com/openclaw/openclaw/issues/99815");
|
||||
provider.client = { request } as unknown as GatewayBrowserClient;
|
||||
|
||||
anchor.dispatchEvent(new MouseEvent("pointerover", { bubbles: true, composed: true }));
|
||||
leave(anchor);
|
||||
await vi.advanceTimersByTimeAsync(250);
|
||||
|
||||
expect(anchor.hasAttribute("aria-haspopup")).toBe(false);
|
||||
expect(anchor.hasAttribute("aria-expanded")).toBe(false);
|
||||
expect(request).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it.each(["pending", "held"])(
|
||||
"retires a %s GitHub preview when its pane becomes inert",
|
||||
async (phase) => {
|
||||
|
|
@ -801,7 +661,7 @@ describe("generic preview portal lifecycle", () => {
|
|||
);
|
||||
|
||||
it("closes when route replacement removes its active link", async () => {
|
||||
const { provider, anchor } = createIssueLink(issuePreviewResponse({ comments: 1 }));
|
||||
const { provider, anchor } = createIssueLink();
|
||||
const route = document.createElement("main");
|
||||
route.append(anchor);
|
||||
provider.append(route);
|
||||
|
|
|
|||
|
|
@ -9,24 +9,13 @@ import {
|
|||
} from "./provider-icon.ts";
|
||||
|
||||
describe("cloud provider presentation", () => {
|
||||
it.each(["aws", "azure", "daytona", "gcp", "google", "google-cloud", "hetzner", "machine0"])(
|
||||
it.each(["google", "machine0"])(
|
||||
"orders backend %s before local/custom profiles regardless of their names",
|
||||
(backend) => {
|
||||
const cloud = { id: "z-local", providerId: "crabbox", providerDisplayId: backend };
|
||||
for (const providerDisplayId of [
|
||||
"incus",
|
||||
"local-container",
|
||||
"docker",
|
||||
"local-docker",
|
||||
"podman",
|
||||
"local-podman",
|
||||
"custom",
|
||||
"constructor",
|
||||
]) {
|
||||
const infrastructure = { id: "a-aws", providerId: "aws", providerDisplayId };
|
||||
expect(compareCloudProfiles(cloud, infrastructure)).toBeLessThan(0);
|
||||
expect(compareCloudProfiles(infrastructure, cloud)).toBeGreaterThan(0);
|
||||
}
|
||||
const infrastructure = { id: "a-aws", providerId: "aws", providerDisplayId: "constructor" };
|
||||
expect(compareCloudProfiles(cloud, infrastructure)).toBeLessThan(0);
|
||||
expect(compareCloudProfiles(infrastructure, cloud)).toBeGreaterThan(0);
|
||||
expect(
|
||||
compareCloudProfiles(
|
||||
{ id: "z-local", providerId: backend },
|
||||
|
|
@ -46,30 +35,21 @@ describe("cloud provider presentation", () => {
|
|||
expect(profiles.toSorted(compareCloudProfiles).map((p) => p.id)).toEqual(["y", "z", "a", "b"]);
|
||||
expect(profiles.map((p) => p.id)).toEqual(["b", "z", "a", "y"]);
|
||||
});
|
||||
it.each(["gcp", "google", "google-cloud"])(
|
||||
"keeps %s separate from model-provider brands",
|
||||
(providerDisplayId) => {
|
||||
const container = document.createElement("div");
|
||||
render(resolveCloudProfileIcon({ providerId: "crabbox", providerDisplayId }).icon, container);
|
||||
expect(container.querySelector('[data-provider-icon="gcp"]')).not.toBeNull();
|
||||
render(renderProviderBrandIcon("google"), container);
|
||||
expect(container.querySelector('[data-provider-icon="gemini"]')).not.toBeNull();
|
||||
},
|
||||
);
|
||||
it("keeps Google Cloud separate from model-provider brands", () => {
|
||||
const container = document.createElement("div");
|
||||
render(
|
||||
resolveCloudProfileIcon({ providerId: "crabbox", providerDisplayId: "google" }).icon,
|
||||
container,
|
||||
);
|
||||
expect(container.querySelector('[data-provider-icon="gcp"]')).not.toBeNull();
|
||||
render(renderProviderBrandIcon("google"), container);
|
||||
expect(container.querySelector('[data-provider-icon="gemini"]')).not.toBeNull();
|
||||
});
|
||||
it.each([
|
||||
["machine0", icons.server],
|
||||
["incus", icons.server],
|
||||
["local-container", icons.box],
|
||||
["docker", icons.box],
|
||||
["local-docker", icons.box],
|
||||
["podman", icons.box],
|
||||
["local-podman", icons.box],
|
||||
["external", icons.cloud],
|
||||
["unknown", icons.cloud],
|
||||
["constructor", icons.cloud],
|
||||
["__proto__", icons.cloud],
|
||||
["toString", icons.cloud],
|
||||
["crabbox", icons.cloud],
|
||||
])("uses shared generic geometry for %s", (providerId, expectedIcon) => {
|
||||
const actual = document.createElement("div"),
|
||||
expected = document.createElement("div");
|
||||
|
|
|
|||
|
|
@ -5,11 +5,6 @@ import type { SidebarRecentSession } from "./app-sidebar-session-types.ts";
|
|||
|
||||
describe("sidebar linked conversation identity", () => {
|
||||
it.each([
|
||||
{
|
||||
name: "route-only WhatsApp direct chat",
|
||||
row: { key: "agent:main:whatsapp:direct:15555550123" },
|
||||
expected: { channelLabel: "WhatsApp", kind: "direct", address: "+15555550123" },
|
||||
},
|
||||
{
|
||||
name: "route-only email direct chat",
|
||||
row: { key: "agent:main:email:direct:alex@example.com" },
|
||||
|
|
@ -24,9 +19,19 @@ describe("sidebar linked conversation identity", () => {
|
|||
name: "current privacy identity instead of an older phone route",
|
||||
row: {
|
||||
key: "agent:main:whatsapp:direct:15555550123",
|
||||
origin: { provider: "whatsapp", from: "100000000@lid", to: "15555550999@s.whatsapp.net" },
|
||||
origin: {
|
||||
provider: "whatsapp",
|
||||
from: "100000000@lid",
|
||||
to: "15555550999@s.whatsapp.net",
|
||||
label: "100000000@lid",
|
||||
},
|
||||
},
|
||||
expected: {
|
||||
channelLabel: "WhatsApp",
|
||||
kind: "direct",
|
||||
address: undefined,
|
||||
conversation: undefined,
|
||||
},
|
||||
expected: { channelLabel: "WhatsApp", kind: "direct", address: undefined },
|
||||
},
|
||||
{
|
||||
name: "WhatsApp phone JID",
|
||||
|
|
@ -55,20 +60,6 @@ describe("sidebar linked conversation identity", () => {
|
|||
address: undefined,
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "WhatsApp privacy identifier",
|
||||
row: {
|
||||
key: "agent:main:whatsapp:direct:100000000@lid",
|
||||
channel: "whatsapp",
|
||||
origin: {
|
||||
provider: "whatsapp",
|
||||
from: "100000000@lid",
|
||||
to: "15555550999@s.whatsapp.net",
|
||||
label: "100000000@lid",
|
||||
},
|
||||
},
|
||||
expected: { channelLabel: "WhatsApp", address: undefined, conversation: undefined },
|
||||
},
|
||||
{
|
||||
name: "Discord thread without redundant origin metadata",
|
||||
row: {
|
||||
|
|
@ -132,19 +123,6 @@ describe("sidebar linked conversation identity", () => {
|
|||
},
|
||||
expected: { channelLabel: "Discord", kind: "thread", conversation: "Example #releases" },
|
||||
},
|
||||
{
|
||||
name: "Slack workspace and channel",
|
||||
row: {
|
||||
key: "agent:main:slack:channel:c123:thread:123.456",
|
||||
channel: "slack",
|
||||
chatType: "channel",
|
||||
subject: "Example #releases",
|
||||
groupChannel: "#releases",
|
||||
space: "T123",
|
||||
origin: { provider: "slack", threadId: "123.456" },
|
||||
},
|
||||
expected: { channelLabel: "Slack", kind: "thread", conversation: "Example #releases" },
|
||||
},
|
||||
{
|
||||
name: "Telegram topic",
|
||||
row: {
|
||||
|
|
@ -193,11 +171,6 @@ describe("sidebar linked conversation identity", () => {
|
|||
row: { key: "agent:main:constructor:direct:42" },
|
||||
expected: { channelLabel: "Constructor", kind: "direct" },
|
||||
},
|
||||
{
|
||||
name: "__proto__ channel from an explicit session key",
|
||||
row: { key: "agent:main:__proto__:channel:example" },
|
||||
expected: { channelLabel: "__proto__", kind: "channel" },
|
||||
},
|
||||
{
|
||||
name: "canonical peer instead of conflicting last-delivery metadata",
|
||||
row: {
|
||||
|
|
@ -224,17 +197,14 @@ describe("sidebar linked conversation identity", () => {
|
|||
expect(projectSidebarSession(row).channelPresentation).toMatchObject(expected);
|
||||
});
|
||||
|
||||
it.each(["agent:main:main", "agent:main:dashboard:chat", "agent:main:work", "global"])(
|
||||
"does not label %s as channel-linked because of its last delivery route",
|
||||
(key) => {
|
||||
expect(
|
||||
projectSidebarSession({
|
||||
key,
|
||||
channel: "whatsapp",
|
||||
chatType: "direct",
|
||||
origin: { provider: "whatsapp", from: "+15555550123" },
|
||||
}).channelPresentation,
|
||||
).toBeUndefined();
|
||||
},
|
||||
);
|
||||
it("does not label a dashboard session as channel-linked because of its last delivery route", () => {
|
||||
expect(
|
||||
projectSidebarSession({
|
||||
key: "agent:main:dashboard:chat",
|
||||
channel: "whatsapp",
|
||||
chatType: "direct",
|
||||
origin: { provider: "whatsapp", from: "+15555550123" },
|
||||
}).channelPresentation,
|
||||
).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { render } from "lit";
|
|||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createDeferred } from "../../../test/helpers/promise.js";
|
||||
import { renderChatSessionSharing } from "../pages/chat/components/chat-session-sharing.ts";
|
||||
import { containers, mountMenu, selectMenuValue } from "../test-helpers/session-menu.ts";
|
||||
import { containers, mountMenu } from "../test-helpers/session-menu.ts";
|
||||
import {
|
||||
createSessionOwnerMenuHarness,
|
||||
sessionOwnerProfiles,
|
||||
|
|
@ -26,38 +26,6 @@ describe("searchable session people", () => {
|
|||
expect(menu.querySelectorAll('[value^="assign-owner:"]')).toHaveLength(1);
|
||||
});
|
||||
|
||||
it.each([21, 1000])(
|
||||
"searches the whole %i-person directory without rendering it all",
|
||||
async (count) => {
|
||||
const names = Array.from(
|
||||
{ length: count },
|
||||
(_, index) => `Person ${String(index).padStart(4, "0")}`,
|
||||
);
|
||||
const { context, request } = createSessionOwnerMenuHarness(() =>
|
||||
sessionOwnerProfiles(...names),
|
||||
);
|
||||
const onAction = vi.fn();
|
||||
const menu = await mountMenu({ context, onAction });
|
||||
await waitForFast(() => expect(request).toHaveBeenCalledWith("users.list", {}));
|
||||
await waitForFast(() => expect(menu.textContent).toContain(names[0]));
|
||||
expect(menu.querySelectorAll('[value^="assign-owner:"]').length).toBeLessThanOrEqual(20);
|
||||
const search = menu.querySelector<HTMLInputElement>('input[type="search"]');
|
||||
expect(search).not.toBeNull();
|
||||
search!.value = names.at(-1)!;
|
||||
search!.dispatchEvent(new InputEvent("input", { bubbles: true }));
|
||||
await waitForFast(() =>
|
||||
expect(menu.querySelectorAll('[value^="assign-owner:"]')).toHaveLength(1),
|
||||
);
|
||||
const row = menu.querySelector<HTMLElement>('[value^="assign-owner:"]')!;
|
||||
expect(row.textContent).toContain(names.at(-1));
|
||||
selectMenuValue(menu, row.getAttribute("value")!);
|
||||
expect(onAction).toHaveBeenCalledWith({
|
||||
kind: "assign-owner",
|
||||
owner: { type: "human", id: `profile-person-${String(count - 1).padStart(4, "0")}` },
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
it("searches beyond the first member page and preserves remove-member selection", async () => {
|
||||
const onMemberChange = vi.fn();
|
||||
const root = document.createElement("div");
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue