mirror of
https://github.com/openclaw/openclaw.git
synced 2026-07-15 04:06:09 +00:00
* refactor(sessions): migrate runtime storage to sqlite * test(sessions): fix sqlite CI regressions * test(sessions): align remaining sqlite fixtures * fix(codex): require sqlite trajectory recorder * test(sessions): align orphan recovery sqlite fixture * test(sessions): align sqlite rebase fixtures * fix(sessions): finish current-main integration of the sqlite flip Resolve the whole-store SDK removal across its owner boundary: drop the loadSessionStore re-export and the registry whole-store wrappers, wire hasTrackedActiveSessionRun into gateway chat, complete the preserveLockedHarnessIds cleanup contract, flip the codex thread-history import to storePath targets, and port remaining main-side tests from file-store helpers to session accessor reads. * chore: drop committed pebbles log, revert plugin-inspector bump, refresh generated docs Remove the 1.8k-line .pebbles/events.jsonl work log from the branch, restore the plugin-inspector advisory lane to main's pinned 0.3.10 so the supply-chain bump gets its own review, and regenerate docs_map, the plugin SDK API baseline, and the export-surface ratchet for the merged tree. * feat(sessions): keep archived transcripts by default with zstd cold storage Codex-style retention: deleting or resetting a session archives its transcript as a zstd-compressed JSONL artifact (plain when the runtime lacks node:zlib zstd) and keeps it until the disk budget evicts oldest first. resetArchiveRetention now governs both deleted and reset archives and defaults to keep; maxDiskBytes defaults to 2gb so retention stays bounded, with archives evicted before live sessions. The cron reaper follows the same knob instead of deleting archives on its own timer. * fix(state): converge agent DB migration lineages and bound database growth Merge coherence: run both structure-gated legacy memory-schema repairs (flip-lineage drop, main-lineage identity rebuild) before the flip migration so pre-flip v1/v2 and pre-merge flip v1/v4 databases all converge, and hoist foreign_keys=OFF outside the schema transaction where the pragma was silently ignored and the v1 sessions rebuild cascade-deleted session_entries. Growth guards: fresh agent DBs enable auto_vacuum=INCREMENTAL, WAL maintenance releases freed pages in bounded passes (never a blocking full VACUUM), and doctor reports state/agent DB bloat from freelist stats. * fix(codex): resolve the store path for thread-history import via the SDK The supervision catalog passed the legacy sessionFile locator to the storePath-targeted transcript mirror; resolve the agent store path with the session-store SDK helper instead of a runtime-object seam so test fakes and headless callers need no extra surface. Drop the obsolete missing-session-id preprocessing case: sessions rows are NOT NULL on session_id and upsert repairs id-less patches at write time. * fix(sessions): fail safe on malformed disk-budget config and doctor stat errors A malformed explicit maxDiskBytes disables the budget instead of falling back to the destructive 2gb default the user never chose, and the doctor bloat check skips databases whose paths stat-fail instead of aborting doctor. * fix(sessions): complete sqlite conflict translations * test(sqlite): align hardening checks with maintenance * test(sessions): inspect compressed transcript archives * fix(tests): await session seeds and drop unused helpers flagged by CI lint The five unawaited writeSessionStoreSeed calls raced their SQLite seeds against the assertions, failing compact shards; the bloat probe drops a useless initializer and the merged tests drop now-unused helpers. * test(sessions): type legacy proof events directly * test(sessions): align hardening contracts * perf(sessions): read usage transcript sizes from SQL aggregates Usage/cost scans walked every session and materialized every transcript event just to re-stringify it for a byte estimate — the #86718 stall class reborn on the DB. readTranscriptStatsSync sums stored JSON bytes in SQLite without loading a single row. * fix(sessions): re-root foreign-root transcript paths onto the current sessions dir Restored backups, moved OPENCLAW_STATE_DIR, and rehearsal copies carry absolute sessionFile paths from the old root; the containment fallback kept those foreign paths, so migration read (and would archive) files in the original root and reported local copies missing. Re-root the canonical agents/<id>/sessions suffix onto the current dir when the file exists there; genuine cross-root layouts still fall through unchanged. * test(agents): seed harness admission through sqlite * fix(sqlite): close agent db on pragma setup failure * fix(doctor): compact and retrofit incremental auto-vacuum after session import The migration is the sanctioned offline window: post-import compact reclaims import churn and applies auto_vacuum=INCREMENTAL to databases created before the fresh-DB pragma existed, so runtime maintenance can release pages in bounded passes on every install. --------- Co-authored-by: Peter Steinberger <steipete@gmail.com>
333 lines
15 KiB
TypeScript
333 lines
15 KiB
TypeScript
// Vitest unit-fast config tests validate fast unit test project setup.
|
|
import path from "node:path";
|
|
import { beforeAll, describe, expect, it } from "vitest";
|
|
import { spawnNodeEvalSync } from "../src/test-utils/node-process.js";
|
|
import { createCommandsLightVitestConfig } from "./vitest/vitest.commands-light.config.ts";
|
|
import { createPluginSdkLightVitestConfig } from "./vitest/vitest.plugin-sdk-light.config.ts";
|
|
import { createUnitFastFakeTimersVitestConfig } from "./vitest/vitest.unit-fast-fake-timers.config.ts";
|
|
import {
|
|
classifyUnitFastTestFileContent,
|
|
collectBroadUnitFastTestCandidates,
|
|
collectUnitFastTestCandidates,
|
|
collectUnitFastTestFileAnalysis,
|
|
forcedUnitFastTestFiles,
|
|
getUnitFastTestFiles,
|
|
getUnitFastTestFilesForIncludePatterns,
|
|
getUnitFastTimerTestFiles,
|
|
isUnitFastTestFile,
|
|
isUnitFastTimerTestFile,
|
|
resolveUnitFastTestIncludePattern,
|
|
resolveUnitFastTimerTestIncludePattern,
|
|
} from "./vitest/vitest.unit-fast-paths.mjs";
|
|
import { createUnitFastVitestConfig } from "./vitest/vitest.unit-fast.config.ts";
|
|
|
|
const ENV_ISOLATION_SETUP_PATH = /[\\/]test[\\/]setup\.env\.ts$/u;
|
|
|
|
function requireTestConfig<T extends { test?: unknown }>(config: T): NonNullable<T["test"]> {
|
|
if (!config.test) {
|
|
throw new Error("expected unit-fast vitest test config");
|
|
}
|
|
return config.test as NonNullable<T["test"]>;
|
|
}
|
|
|
|
function countMatching<T>(items: readonly T[], predicate: (item: T) => boolean): number {
|
|
let count = 0;
|
|
for (const item of items) {
|
|
if (predicate(item)) {
|
|
count += 1;
|
|
}
|
|
}
|
|
return count;
|
|
}
|
|
|
|
type UnitFastAnalysisEntry = ReturnType<typeof collectUnitFastTestFileAnalysis>[number];
|
|
|
|
function collectUnroutedForcedFiles(
|
|
analysis: readonly UnitFastAnalysisEntry[],
|
|
forcedFiles: ReadonlySet<string>,
|
|
): Array<{ file: string; forced: boolean; unitFast: boolean }> {
|
|
const unrouted: Array<{ file: string; forced: boolean; unitFast: boolean }> = [];
|
|
for (const entry of analysis) {
|
|
if (!forcedFiles.has(entry.file)) {
|
|
continue;
|
|
}
|
|
if (!entry.forced || !entry.unitFast) {
|
|
unrouted.push({ file: entry.file, forced: entry.forced, unitFast: entry.unitFast });
|
|
}
|
|
}
|
|
return unrouted;
|
|
}
|
|
|
|
describe("unit-fast vitest lane", () => {
|
|
let configProbeResult: ReturnType<typeof spawnNodeEvalSync>;
|
|
let unitFastConfig: ReturnType<typeof createUnitFastVitestConfig>;
|
|
let unitFastTestFiles: ReturnType<typeof getUnitFastTestFiles>;
|
|
let unitFastTimerTestFiles: ReturnType<typeof getUnitFastTimerTestFiles>;
|
|
let unitFastAnalysis: ReturnType<typeof collectUnitFastTestFileAnalysis>;
|
|
let broadCandidates: ReturnType<typeof collectBroadUnitFastTestCandidates>;
|
|
let broadAnalysis: ReturnType<typeof collectUnitFastTestFileAnalysis>;
|
|
let currentCandidates: ReturnType<typeof collectUnitFastTestCandidates>;
|
|
|
|
beforeAll(() => {
|
|
const script = `
|
|
import childProcess from "node:child_process";
|
|
import fs from "node:fs";
|
|
import { syncBuiltinESMExports } from "node:module";
|
|
let gitLsFilesCalls = 0;
|
|
const originalSpawnSync = childProcess.spawnSync;
|
|
childProcess.spawnSync = function patchedSpawnSync(...args) {
|
|
const [command, commandArgs] = args;
|
|
if (command === "git" && commandArgs?.[0] === "ls-files") {
|
|
gitLsFilesCalls += 1;
|
|
const stdout = [
|
|
"src/agents/agent-tools.deferred-followup-guidance.test.ts",
|
|
"src/hooks/frontmatter.test.ts",
|
|
].join("\\n") + "\\n";
|
|
return {
|
|
pid: 0,
|
|
output: [null, stdout, ""],
|
|
stdout,
|
|
stderr: "",
|
|
status: 0,
|
|
signal: null,
|
|
};
|
|
}
|
|
return originalSpawnSync.apply(this, args);
|
|
};
|
|
syncBuiltinESMExports();
|
|
let readdirSyncCalls = 0;
|
|
let hookFileReads = 0;
|
|
let outsideFileReads = 0;
|
|
const originalReaddirSync = fs.readdirSync;
|
|
const originalReadFileSync = fs.readFileSync;
|
|
fs.readdirSync = function patchedReaddirSync(...args) {
|
|
readdirSyncCalls += 1;
|
|
return originalReaddirSync.apply(this, args);
|
|
};
|
|
fs.readFileSync = function patchedReadFileSync(...args) {
|
|
const file = String(args[0]).replaceAll("\\\\", "/");
|
|
if (file.endsWith("/src/hooks/frontmatter.test.ts")) {
|
|
hookFileReads += 1;
|
|
} else if (file.endsWith("/src/agents/agent-tools.deferred-followup-guidance.test.ts")) {
|
|
outsideFileReads += 1;
|
|
}
|
|
return originalReadFileSync.apply(this, args);
|
|
};
|
|
await import("./test/vitest/vitest.hooks.config.ts?scope-probe=" + Date.now());
|
|
const scopedHookFileReads = hookFileReads;
|
|
const scopedOutsideFileReads = outsideFileReads;
|
|
await import("./test/vitest/vitest.unit-fast.config.ts?io-probe=" + Date.now());
|
|
console.log(
|
|
"UNIT_FAST_IO_PROBE",
|
|
gitLsFilesCalls,
|
|
readdirSyncCalls,
|
|
scopedHookFileReads,
|
|
scopedOutsideFileReads,
|
|
);
|
|
`;
|
|
configProbeResult = spawnNodeEvalSync(script, {
|
|
env: {
|
|
...process.env,
|
|
FORCE_COLOR: "0",
|
|
NO_COLOR: "1",
|
|
OPENCLAW_VITEST_INCLUDE_FILE: undefined,
|
|
},
|
|
evalFlag: "-e",
|
|
imports: ["tsx"],
|
|
});
|
|
unitFastConfig = createUnitFastVitestConfig({});
|
|
unitFastTestFiles = getUnitFastTestFiles();
|
|
unitFastTimerTestFiles = getUnitFastTimerTestFiles();
|
|
unitFastAnalysis = collectUnitFastTestFileAnalysis();
|
|
currentCandidates = collectUnitFastTestCandidates();
|
|
broadCandidates = collectBroadUnitFastTestCandidates();
|
|
broadAnalysis = collectUnitFastTestFileAnalysis(process.cwd(), { scope: "broad" });
|
|
});
|
|
|
|
it("loads the config without recursively walking repo roots", () => {
|
|
expect(configProbeResult.status, configProbeResult.stderr).toBe(0);
|
|
const probeMatch = configProbeResult.stdout.match(
|
|
/UNIT_FAST_IO_PROBE (\d+) (\d+) (\d+) (\d+)/u,
|
|
);
|
|
expect(probeMatch, configProbeResult.stdout).not.toBeNull();
|
|
expect(Number(probeMatch?.[1])).toBe(1);
|
|
expect(Number(probeMatch?.[2])).toBeLessThan(20);
|
|
expect(Number(probeMatch?.[3])).toBe(1);
|
|
expect(Number(probeMatch?.[4])).toBe(0);
|
|
});
|
|
|
|
it("runs cache-friendly tests without the reset-heavy runner or runtime setup", () => {
|
|
const testConfig = requireTestConfig(unitFastConfig);
|
|
|
|
expect(testConfig.isolate).toBe(false);
|
|
expect(testConfig.runner).toBeUndefined();
|
|
// Env isolation only: the fast shards install the isolated test home but
|
|
// must not pull in the shared setup's module mocks.
|
|
expect(testConfig.setupFiles).toStrictEqual([expect.stringMatching(ENV_ISOLATION_SETUP_PATH)]);
|
|
expect(testConfig.include).toContain(
|
|
"src/agents/agent-tools.deferred-followup-guidance.test.ts",
|
|
);
|
|
expect(testConfig.include).toContain("src/acp/control-plane/runtime-cache.test.ts");
|
|
expect(testConfig.include).toContain("src/acp/runtime/registry.test.ts");
|
|
expect(testConfig.include).toContain("src/commands/status-overview-values.test.ts");
|
|
expect(testConfig.include).toContain("src/entry.version-fast-path.test.ts");
|
|
expect(testConfig.include).toContain("src/flows/doctor-startup-channel-maintenance.test.ts");
|
|
expect(testConfig.include).toContain("src/crestodian/rescue-policy.test.ts");
|
|
expect(testConfig.include).toContain("src/crestodian/assistant.configured.test.ts");
|
|
expect(testConfig.include).toContain("src/flows/search-setup.test.ts");
|
|
expect(testConfig.include).toContain("src/plugins/config-policy.test.ts");
|
|
expect(testConfig.include).toContain("src/proxy-capture/proxy-server.test.ts");
|
|
expect(testConfig.include).toContain("src/talk/agent-consult-tool.test.ts");
|
|
expect(testConfig.include).toContain("src/sessions/session-lifecycle-events.test.ts");
|
|
expect(testConfig.include).toContain("src/sessions/transcript-events.test.ts");
|
|
expect(testConfig.include).toContain("src/security/audit-channel-source-config-slack.test.ts");
|
|
expect(testConfig.include).toContain("src/security/audit-config-symlink.test.ts");
|
|
expect(testConfig.include).toContain("src/security/audit-exec-sandbox-host.test.ts");
|
|
expect(testConfig.include).toContain("src/security/audit-gateway.test.ts");
|
|
expect(testConfig.include).toContain("src/security/audit-gateway-auth-selection.test.ts");
|
|
expect(testConfig.include).toContain("src/security/audit-gateway-http-auth.test.ts");
|
|
expect(testConfig.include).toContain("src/security/audit-gateway-tools-http.test.ts");
|
|
expect(testConfig.include).toContain("src/security/audit-plugin-readonly-scope.test.ts");
|
|
expect(testConfig.include).toContain("src/security/audit-loopback-logging.test.ts");
|
|
expect(testConfig.include).toContain("src/video-generation/provider-registry.test.ts");
|
|
expect(testConfig.include).toContain("src/plugin-sdk/provider-entry.test.ts");
|
|
expect(testConfig.include).toContain("src/security/dangerous-config-flags.test.ts");
|
|
expect(testConfig.include).toContain("src/security/context-visibility.test.ts");
|
|
expect(testConfig.include).toContain("src/security/safe-regex.test.ts");
|
|
});
|
|
|
|
it("does not treat moved config paths as CLI include filters", () => {
|
|
const config = createUnitFastVitestConfig(
|
|
{},
|
|
{
|
|
argv: ["node", "vitest", "run", "--config", "test/vitest/vitest.unit-fast.config.ts"],
|
|
},
|
|
);
|
|
|
|
const testConfig = requireTestConfig(config);
|
|
expect(testConfig.include).toContain("src/plugin-sdk/provider-entry.test.ts");
|
|
expect(testConfig.include).toContain("src/commands/status-overview-values.test.ts");
|
|
});
|
|
|
|
it("keeps obvious stateful files out of the unit-fast lane", () => {
|
|
expect(isUnitFastTestFile("src/plugin-sdk/temp-path.test.ts")).toBe(false);
|
|
expect(isUnitFastTestFile("src/agents/sandbox.resolveSandboxContext.test.ts")).toBe(false);
|
|
expect(isUnitFastTestFile("src/acp/runtime/session-meta.test.ts")).toBe(false);
|
|
expect(isUnitFastTestFile("src/crestodian/assistant.test.ts")).toBe(false);
|
|
expect(isUnitFastTestFile("src/flows/channel-setup.test.ts")).toBe(false);
|
|
expect(isUnitFastTestFile("src/flows/doctor-health-contributions.test.ts")).toBe(false);
|
|
expect(isUnitFastTestFile("src/plugins/install.npm-spec.test.ts")).toBe(false);
|
|
expect(isUnitFastTestFile("src/secrets/runtime.test.ts")).toBe(false);
|
|
expect(resolveUnitFastTestIncludePattern("src/plugin-sdk/temp-path.ts")).toBeNull();
|
|
expect(classifyUnitFastTestFileContent("vi.resetModules(); await import('./x.js')")).toEqual([
|
|
"module-mocking",
|
|
"vitest-mock-api",
|
|
"dynamic-import",
|
|
]);
|
|
});
|
|
|
|
it("routes unit-fast source files to their unit-fast sibling tests", () => {
|
|
expect(resolveUnitFastTestIncludePattern("src/plugin-sdk/provider-entry.ts")).toBe(
|
|
"src/plugin-sdk/provider-entry.test.ts",
|
|
);
|
|
expect(resolveUnitFastTestIncludePattern("src/commands/status-overview-values.ts")).toBe(
|
|
"src/commands/status-overview-values.test.ts",
|
|
);
|
|
});
|
|
|
|
it("routes audited stateful-looking tests through the fast lane", () => {
|
|
const forcedFileSet = new Set(forcedUnitFastTestFiles);
|
|
const forcedAnalysisCount = countMatching(unitFastAnalysis, (entry) =>
|
|
forcedFileSet.has(entry.file),
|
|
);
|
|
|
|
expect(forcedAnalysisCount).toBe(forcedUnitFastTestFiles.length);
|
|
for (const file of forcedUnitFastTestFiles) {
|
|
expect(unitFastTestFiles).toContain(file);
|
|
expect(isUnitFastTestFile(file)).toBe(true);
|
|
}
|
|
const unroutedForcedFiles = collectUnroutedForcedFiles(unitFastAnalysis, forcedFileSet);
|
|
expect(unroutedForcedFiles).toStrictEqual([]);
|
|
});
|
|
|
|
it("routes fake-timer unit-fast tests through the serial fake-timer lane", () => {
|
|
const fakeTimerFiles = unitFastAnalysis
|
|
.filter((entry) => entry.unitFast && entry.reasons.includes("fake-timers"))
|
|
.map((entry) => entry.file);
|
|
expect(unitFastTimerTestFiles.length).toBeGreaterThan(0);
|
|
expect(unitFastTimerTestFiles).toEqual(fakeTimerFiles);
|
|
for (const file of unitFastTimerTestFiles) {
|
|
expect(isUnitFastTimerTestFile(file)).toBe(true);
|
|
expect(resolveUnitFastTestIncludePattern(file)).toBeNull();
|
|
expect(resolveUnitFastTimerTestIncludePattern(file)).toBe(file);
|
|
}
|
|
|
|
const fastConfig = requireTestConfig(unitFastConfig);
|
|
const timerConfig = requireTestConfig(createUnitFastFakeTimersVitestConfig({}));
|
|
expect(fastConfig.include).not.toEqual(expect.arrayContaining(unitFastTimerTestFiles));
|
|
expect(timerConfig.include).toEqual(unitFastTimerTestFiles);
|
|
expect(timerConfig.fileParallelism).toBe(false);
|
|
expect(timerConfig.maxWorkers).toBe(1);
|
|
expect(timerConfig.setupFiles).toStrictEqual([expect.stringMatching(ENV_ISOLATION_SETUP_PATH)]);
|
|
});
|
|
|
|
it("keeps broad audit candidates separate from automatically routed unit-fast tests", () => {
|
|
expect(currentCandidates.length).toBeGreaterThanOrEqual(unitFastTestFiles.length);
|
|
expect(broadCandidates.length).toBeGreaterThan(currentCandidates.length);
|
|
expect(countMatching(broadAnalysis, (entry) => entry.unitFast)).toBeGreaterThan(
|
|
unitFastTestFiles.length,
|
|
);
|
|
});
|
|
|
|
it("keeps scoped unit-fast exclusions equivalent to the full inventory", () => {
|
|
const cases = [
|
|
{ dir: "src/hooks", patterns: ["src/hooks/**/*.test.ts"] },
|
|
{ dir: "src", patterns: ["src/agents/*/**/*.test.ts"] },
|
|
{ dir: "src/acp", patterns: ["src/acp/client.test.ts"] },
|
|
{ dir: "extensions", patterns: ["extensions/**/*.test.ts"] },
|
|
{ dir: undefined, patterns: ["test/**/*.test.ts"] },
|
|
{ dir: undefined, patterns: ["src/{hooks,infra}/**/*.test.ts"] },
|
|
{ dir: "src", patterns: [] },
|
|
];
|
|
|
|
for (const { dir, patterns } of cases) {
|
|
const prefix = dir ? `${dir}/` : "";
|
|
const expected = unitFastTestFiles.filter((file) => {
|
|
if (prefix && !file.startsWith(prefix)) {
|
|
return false;
|
|
}
|
|
return patterns.some((pattern) => path.matchesGlob(file, pattern));
|
|
});
|
|
expect(getUnitFastTestFilesForIncludePatterns(patterns, { dir })).toEqual(expected);
|
|
}
|
|
|
|
const extensionUnitFastFiles = getUnitFastTestFilesForIncludePatterns(
|
|
["extensions/**/*.test.ts"],
|
|
{ dir: "extensions" },
|
|
);
|
|
expect(getUnitFastTestFilesForIncludePatterns(["**/*.test.ts"], { dir: "extensions" })).toEqual(
|
|
extensionUnitFastFiles,
|
|
);
|
|
expect(extensionUnitFastFiles).toEqual(
|
|
expect.arrayContaining([
|
|
"extensions/canvas/src/host/server.test.ts",
|
|
"extensions/canvas/src/host/server.state-dir.test.ts",
|
|
]),
|
|
);
|
|
expect(getUnitFastTestFilesForIncludePatterns(["!src/**/*.test.ts"])).toEqual(
|
|
unitFastTestFiles,
|
|
);
|
|
});
|
|
|
|
it("excludes unit-fast files from the older light lanes so full runs do not duplicate them", () => {
|
|
const pluginSdkLight = createPluginSdkLightVitestConfig({});
|
|
const commandsLight = createCommandsLightVitestConfig({});
|
|
|
|
expect(unitFastTestFiles).toContain("src/plugin-sdk/provider-entry.test.ts");
|
|
expect(requireTestConfig(pluginSdkLight).exclude).toContain(
|
|
"plugin-sdk/provider-entry.test.ts",
|
|
);
|
|
expect(requireTestConfig(commandsLight).exclude).toContain("status-overview-values.test.ts");
|
|
});
|
|
});
|