mirror of
https://github.com/openclaw/openclaw.git
synced 2026-09-28 05:54:09 +08:00
* fix(startup): keep legacy state repair in doctor Separate current-state startup readiness from explicit Doctor migrations. Preserve current config recovery, quarantine, device identity checks, lease fencing and updater completion ownership. Remove automatic startup migration/checkpoint and node-host import paths. Keep shipped read-only context and roster projections unchanged. * test(doctor): align proof callers with repair ownership Keep survivor fixture setup in caller order and select the shared Doctor flow separately from the channel-specific proof. Include the complete isolated diagnostics dependency closure. Prove ordinary startup preserves legacy directories before explicit Doctor repair, and await SQLite worker closure before test cleanup. * test(startup): verify index repair through gateway maintenance * test(doctor): align cutover fixtures with state owners Reacquire the database after Doctor retires its generation, retain real session history for startup refusal, and keep pending reads at their actual owners. Distinguish the reused readonly reader from independent snapshot-token children and verify every child settles. * fix(doctor): preserve image activation and published migration receipts Run the shared noninteractive Doctor owner before default and Compose Gateway activation so retained Docker volumes keep their published upgrade path while ordinary Gateway startup stays readiness-only. Preserve root selectors and settle interrupted repair before executing the original command. Retain the path-wide tombstones emitted by the published restart-sentinel importer, including consumed notices, and validate completed source decisions before retiring recreated inputs. Add the root-image activation lane and causal receipt coverage without introducing a schema, option, or second importer. * test(docker): preserve release state pairs in upgrade proof Use the existing synthetic v2026.9.2 corpus instead of combining a July shared database with a later agent schema. Preserve its deletion journal, registry, both transcript payloads, paired backups, and unsafe-state controls. Register the two shell-launched helpers with Knip. * test(docker): normalize persisted schema snapshot rows Compare node:sqlite schema rows using the same plain-object representation as the saved JSON preimage. Keep strict schema, row, and value-type assertions intact. * fix(models): retain discovered models after refresh failures Record successful legacy catalog results at the producer boundary so unavailable refreshes retain the accepted inventory. Preserve explicit outcomes, advisory SDK fallback behavior, and first-discovery starter policy. * fix(models): preserve skipped catalog outcome semantics Mark bundled static, configured, and advisory catalog projections with explicit empty outcomes so legacy success inference cannot promote them to observed account inventory. Preserve live outcomes and helper types. Keep exact auth provenance histories and move existing fixture/policy code into focused owners where required by the line-cap ratchet. Validation: 447 producer and sibling cases, 56 shared self-hosted cases, 95 auth/policy cases, causal missing-outcome failures, maintained checks, and independent review. * test(plugin-sdk): keep discovery loader types acyclic Move the shared loader type into a leaf consumed by both discovery contract helpers. Preserve its public provider-test-contracts export without a child-to-parent type import cycle. Validation: maintained Madge check reports zero cycles; core, all core test graphs, extension test types, lint, formatting and independent review pass. Runtime behavior and previous catalog proof are unchanged. * fix(plugin-sdk): mark generated static catalogs explicitly Keep the generated non-live, non-strict catalog adapter from claiming successful acquisition for manifest or configured rows. Preserve null, errors, strict and custom callbacks, static catalogs, and public types. Validation: three existing controls fail before the correction; all49 owner and sibling cases pass afterward, with types, lint, line caps and fresh independent review clean. * test(gateway): cover restart import during state retirement Exercise canonical database close while a legacy notice read is paused. Verify admission rejection, retained canonical and source bytes, no migration receipt, and joined maintenance custody without changing the accepted sidecar-stop drain contract. * fix(test): drain sharing fixtures before removing state * test: bind retirement regression to its own worker * docs(docker): clarify automatic Doctor activation * test(doctor): keep readiness fixture runtime private * test: stabilize shared skill watcher fixture roots (cherry picked from commit15606be10e) * perf(tooling): share indexed scope parsing for artifact scans (cherry picked from commit6e6eef9f5b) * fix(team-reports): use source owners in scheduler tests The source barrel retired in #157819, but the scheduler tests still imported it, breaking the extension test typecheck on main. Import the Discord and GitHub owners directly, matching the production caller. Validated the original TS2307/TS7006 failure, the corrected extension type graph, all 36 scheduler tests, changed checks, and independent P2 review. (cherry picked from commit7c4866c73b) * test(install): isolate global npm configuration in version fixtures Use a controlled absent global config inside the fixture home so the release helper does not query the deliberately narrow npm stub. Keep predecessor selection, fresh-install behavior, and expected exits intact. (cherry picked from commit0a9eefc76c) * test(docker): verify the complete image activation entrypoint
184 lines
6.2 KiB
TypeScript
184 lines
6.2 KiB
TypeScript
#!/usr/bin/env node
|
|
// Cheap guard for Docker E2E test boundaries.
|
|
// Docker E2E must test packaged npm tarballs and package-installed images, not
|
|
// the source checkout copied or mounted as the app under test.
|
|
import fs from "node:fs";
|
|
import path from "node:path";
|
|
import packageJson from "../package.json" with { type: "json" };
|
|
import { laneResources, lanesNeedOpenClawPackage, laneWeight } from "./lib/docker-e2e-plan.mts";
|
|
import {
|
|
allReleasePathLanes,
|
|
mainLanes,
|
|
publicInstallerLanes,
|
|
tailLanes,
|
|
} from "./lib/docker-e2e-scenarios.mts";
|
|
import { resolveRepoRoot } from "./lib/repo-root.mjs";
|
|
const ROOT_DIR = resolveRepoRoot(import.meta.url);
|
|
const errors: string[] = [];
|
|
const packageScripts = new Set(Object.keys(packageJson.scripts ?? {}));
|
|
// These lanes prove package-installed surfaces against live auth, so they
|
|
// intentionally need both live credentials and a package-backed image.
|
|
const livePackageBackedLanes = new Set([
|
|
"install-e2e-anthropic",
|
|
"install-e2e-openai",
|
|
"live-anthropic-cache",
|
|
"live-codex-npm-plugin",
|
|
"live-mcp-code-mode-gateway",
|
|
"live-plugin-tool",
|
|
"npm-telegram-live",
|
|
"openai-chat-tools",
|
|
"openwebui",
|
|
]);
|
|
// These lanes intentionally build a focused source-checkout image instead of
|
|
// consuming the shared package E2E images.
|
|
const sourceCheckoutImageLanes = new Set([
|
|
"container-image-upgrade",
|
|
"docker-selected-plugins",
|
|
"plugin-binding-command-escape",
|
|
]);
|
|
|
|
function readText(relativePath: string) {
|
|
return fs.readFileSync(path.join(ROOT_DIR, relativePath), "utf8");
|
|
}
|
|
|
|
function walk(dir: string, out: string[] = []) {
|
|
for (const entry of fs.readdirSync(path.join(ROOT_DIR, dir), { withFileTypes: true })) {
|
|
const relativePath = path.join(dir, entry.name);
|
|
if (entry.isDirectory()) {
|
|
walk(relativePath, out);
|
|
} else {
|
|
out.push(relativePath);
|
|
}
|
|
}
|
|
return out;
|
|
}
|
|
|
|
function findRelativeModuleSpecifiers(text: string) {
|
|
const specifiers = new Set<string>();
|
|
for (const pattern of [
|
|
/(?:\bfrom\s*|\bimport\s*\()\s*["']([^"']+)["']/gu,
|
|
/\bimport\s*["']([^"']+)["']/gu,
|
|
]) {
|
|
for (const match of text.matchAll(pattern)) {
|
|
const specifier = match[1];
|
|
if (specifier?.startsWith(".")) {
|
|
specifiers.add(specifier);
|
|
}
|
|
}
|
|
}
|
|
return [...specifiers];
|
|
}
|
|
|
|
function isPathWithin(parent: string, candidate: string) {
|
|
const relative = path.relative(parent, candidate);
|
|
return (
|
|
relative === "" ||
|
|
(!path.isAbsolute(relative) && !relative.startsWith(`..${path.sep}`) && relative !== "..")
|
|
);
|
|
}
|
|
|
|
for (const relativePath of walk("scripts/e2e")) {
|
|
if (!/\.(?:sh|ts|mts|mjs|js)$/u.test(relativePath)) {
|
|
continue;
|
|
}
|
|
const text = readText(relativePath);
|
|
const sourceImport = findRelativeModuleSpecifiers(text).find((specifier) => {
|
|
const resolved = path.resolve(ROOT_DIR, path.dirname(relativePath), specifier);
|
|
return isPathWithin(path.join(ROOT_DIR, "src"), resolved);
|
|
});
|
|
if (sourceImport) {
|
|
errors.push(
|
|
`${relativePath}: Docker E2E harness must import package exports, not ${sourceImport}`,
|
|
);
|
|
}
|
|
if (/-v\s+["']?\$ROOT_DIR:\/app(?::|["'\s]|$)/u.test(text)) {
|
|
errors.push(`${relativePath}: do not mount the repo root as /app in Docker E2E`);
|
|
}
|
|
}
|
|
|
|
const dockerfile = readText("scripts/e2e/Dockerfile");
|
|
if (/^\s*(?:COPY|ADD)\s+\.\s+\/app(?:\s|$)/imu.test(dockerfile)) {
|
|
errors.push("scripts/e2e/Dockerfile: do not copy the source checkout into /app");
|
|
}
|
|
|
|
function validateUniqueLanes(label: string, lanes: readonly (typeof mainLanes)[number][]) {
|
|
const seen = new Set<string>();
|
|
for (const lane of lanes) {
|
|
if (seen.has(lane.name)) {
|
|
errors.push(`${label}: duplicate Docker E2E lane '${lane.name}'`);
|
|
}
|
|
seen.add(lane.name);
|
|
}
|
|
}
|
|
|
|
function validateLane(label: string, lane: (typeof mainLanes)[number]) {
|
|
const resources = laneResources(lane);
|
|
const sourceCheckoutImageLane = sourceCheckoutImageLanes.has(lane.name);
|
|
const needsPackage = lanesNeedOpenClawPackage([lane]);
|
|
const localImageBuild = sourceCheckoutImageLane || (needsPackage && !lane.e2eImageKind);
|
|
if (!lane.name || typeof lane.name !== "string") {
|
|
errors.push(`${label}: Docker E2E lane is missing a string name`);
|
|
}
|
|
if (!lane.command || typeof lane.command !== "string") {
|
|
errors.push(`${label}: Docker E2E lane '${lane.name}' is missing a string command`);
|
|
return;
|
|
}
|
|
if (lane.e2eImageKind && lane.e2eImageKind !== "bare" && lane.e2eImageKind !== "functional") {
|
|
const invalidImageKind =
|
|
typeof lane.e2eImageKind === "string"
|
|
? lane.e2eImageKind
|
|
: (JSON.stringify(lane.e2eImageKind) ?? "<invalid>");
|
|
errors.push(
|
|
`${label}: Docker E2E lane '${lane.name}' has invalid image kind '${invalidImageKind}'`,
|
|
);
|
|
}
|
|
if (lane.live && needsPackage && !livePackageBackedLanes.has(lane.name)) {
|
|
errors.push(`${label}: live Docker E2E lane '${lane.name}' must not require a package image`);
|
|
}
|
|
if (!lane.live && !needsPackage && !sourceCheckoutImageLane) {
|
|
errors.push(
|
|
`${label}: package Docker E2E lane '${lane.name}' must request package preparation`,
|
|
);
|
|
}
|
|
if (localImageBuild && !/\bOPENCLAW_SKIP_DOCKER_BUILD=0\b/u.test(lane.command)) {
|
|
errors.push(
|
|
`${label}: locally built Docker E2E lane '${lane.name}' must force a local image build`,
|
|
);
|
|
}
|
|
if (laneWeight(lane) < 1) {
|
|
errors.push(`${label}: Docker E2E lane '${lane.name}' must have positive weight`);
|
|
}
|
|
if (!resources.includes("docker")) {
|
|
errors.push(`${label}: Docker E2E lane '${lane.name}' must include the docker resource`);
|
|
}
|
|
|
|
for (const match of lane.command.matchAll(/\bpnpm\s+([^\s]+)/gu)) {
|
|
const script = match[1];
|
|
if (script && !packageScripts.has(script)) {
|
|
errors.push(
|
|
`${label}: Docker E2E lane '${lane.name}' references missing package script '${script}'`,
|
|
);
|
|
}
|
|
}
|
|
}
|
|
|
|
const releasePathLanes = allReleasePathLanes({ includeOpenWebUI: true });
|
|
for (const [label, lanes] of [
|
|
["release-path", releasePathLanes],
|
|
["public-installer", publicInstallerLanes],
|
|
["main", mainLanes],
|
|
["tail", tailLanes],
|
|
] as const) {
|
|
validateUniqueLanes(label, lanes);
|
|
for (const lane of lanes) {
|
|
validateLane(label, lane);
|
|
}
|
|
}
|
|
|
|
if (errors.length > 0) {
|
|
console.error(errors.join("\n"));
|
|
process.exit(1);
|
|
}
|
|
|
|
console.log("Docker E2E package boundary/catalog guard passed.");
|