Files
ThothII/backend/test/workspace-registry-deployment.test.ts
T

334 lines
15 KiB
TypeScript

import { execFile, execFileSync } from "node:child_process";
import { existsSync, mkdtempSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { promisify } from "node:util";
import { afterEach, expect, test } from "vitest";
import { WorkspaceRegistry } from "../src/workspaces/registry.js";
import { parseWorkspaceYaml, serializeWorkspaceYaml, type CanonicalWorkspace } from "../src/workspaces/schema.js";
import type { WorkspaceRegistryConfig } from "../src/workspaces/types.js";
const runFile = promisify(execFile);
const temporaryRoots: string[] = [];
afterEach(() => {
temporaryRoots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true }));
});
function readFixture(name: string): string {
return readFileSync(new URL(`../../scripts/fixtures/${name}`, import.meta.url), "utf8");
}
function catalogYaml(workspace: CanonicalWorkspace): string {
const { id, name, description } = workspace.workspace;
return `schema_version: 1
workspaces:
- id: ${id}
name: ${name}${description ? `
description: ${description}` : ""}
`;
}
async function git(cwd: string, args: string[]): Promise<void> {
await runFile("git", args, { cwd });
}
async function gitOutput(cwd: string, args: string[]): Promise<string> {
const { stdout } = await runFile("git", args, { cwd });
return stdout.trim();
}
async function commitAll(cwd: string, message: string): Promise<string> {
await git(cwd, ["add", "-A"]);
await git(cwd, [
"-c",
"user.name=Workspace Registry Deployment Test",
"-c",
"user.email=workspace-registry-deployment@example.invalid",
"commit",
"-m",
message,
]);
await git(cwd, ["push", "origin", "main"]);
return await gitOutput(cwd, ["rev-parse", "HEAD"]);
}
function registryConfig(root: string, remoteUrl: string): WorkspaceRegistryConfig {
return {
root,
remoteUrl,
branch: "main",
gitAuthorName: "Workspace Registry Deployment Test",
gitAuthorEmail: "workspace-registry-deployment@example.invalid",
installationId: "deployment-test",
secretRoots: [],
maxImportBytes: 1024,
maxImportEntries: 4,
};
}
function workspaceVariant(
workspace: CanonicalWorkspace,
changes: Partial<CanonicalWorkspace["workspace"]>,
): CanonicalWorkspace {
const id = changes.id ?? workspace.workspace.id;
return {
...workspace,
workspace: { ...workspace.workspace, ...changes, id },
...(workspace.evidence?.source.type === "filesystem"
? {
evidence: {
...workspace.evidence,
source: { ...workspace.evidence.source, uri: `${id}/evidence` },
},
}
: {}),
};
}
async function createRegistryFixture(workspace: CanonicalWorkspace): Promise<{
root: string;
source: string;
remote: string;
registry: WorkspaceRegistry;
}> {
const root = mkdtempSync(join(tmpdir(), "thoth-workspace-registry-deployment-"));
temporaryRoots.push(root);
const source = join(root, "source");
const remote = join(root, "remote.git");
mkdirSync(source, { recursive: true });
await git(root, ["init", "--bare", "--initial-branch=main", remote]);
await git(source, ["init", "--initial-branch=main"]);
await git(source, ["config", "user.name", "Workspace Registry Deployment Test"]);
await git(source, ["config", "user.email", "workspace-registry-deployment@example.invalid"]);
const id = workspace.workspace.id;
mkdirSync(join(source, id), { recursive: true });
writeFileSync(join(source, "thoth-workspaces.yaml"), catalogYaml(workspace));
writeFileSync(join(source, id, "workspace.yaml"), serializeWorkspaceYaml(workspace));
if (workspace.evidence?.source.type === "filesystem") {
mkdirSync(join(source, id, "evidence"), { recursive: true });
writeFileSync(join(source, id, "evidence", "guide.md"), "guide v1\n");
}
await git(source, ["add", "-A"]);
await git(source, ["commit", "-m", "Seed workspace registry deployment fixture"]);
await git(source, ["remote", "add", "origin", remote]);
await git(source, ["push", "origin", "main"]);
return {
root,
source,
remote,
registry: new WorkspaceRegistry(registryConfig(join(root, "registry"), remote)),
};
}
test("declares a durable isolated registry volume and installs fixtures under the root catalog contract", () => {
const compose = readFileSync(new URL("../../compose.yaml", import.meta.url), "utf8");
const development = readFileSync(new URL("../../docker-compose.dev.yml", import.meta.url), "utf8");
const gitHttps = readFileSync(new URL("../../deploy/compose.git-https.yaml", import.meta.url), "utf8");
const gitSsh = readFileSync(new URL("../../deploy/compose.git-ssh.yaml", import.meta.url), "utf8");
const dockerfile = readFileSync(new URL("../../docker/core.Dockerfile", import.meta.url), "utf8");
const smoke = readFileSync(new URL("../../scripts/workspace-registry-smoke.sh", import.meta.url), "utf8");
const unified = readFileSync(new URL("../../scripts/unified-deployment-smoke.sh", import.meta.url), "utf8");
const windows = readFileSync(new URL("../../scripts/test-windows-clone-contract.ps1", import.meta.url), "utf8");
for (const source of [compose, development]) {
expect(source).toContain("THT_WORKSPACE_REGISTRY_ROOT: /data/workspace-registry");
expect(source).toContain("THT_WORKSPACE_GIT_REMOTE: ${THT_WORKSPACE_GIT_REMOTE:?set THT_WORKSPACE_GIT_REMOTE}");
expect(source).toContain("workspace-registry:/data/workspace-registry");
}
expect(compose).not.toMatch(/workspace-registry-git-(?:credentials|ca|ssh-key|known-hosts):ro/);
expect(gitHttps).toMatch(/workspace-registry-git-credentials:ro/);
expect(gitHttps).toMatch(/workspace-registry-git-ca:ro/);
expect(gitSsh).toMatch(/workspace-registry-git-ssh-key:ro/);
expect(gitSsh).toMatch(/workspace-registry-git-known-hosts:ro/);
expect(dockerfile).toMatch(/mkdir -p[^\n]*\/data\/workspace-registry/);
expect(dockerfile).toMatch(/chown -R thoth:thoth \/home\/thoth\/\.pi \/data/);
expect(smoke).toContain('--fixtures-only');
expect(smoke).toContain('thoth-workspaces.yaml');
expect(smoke).toContain('$seed/$workspace_registry_smoke_id/workspace.yaml');
expect(smoke).toContain('$seed/$workspace_registry_smoke_id/evidence/guide.md');
expect(smoke).not.toContain('"$seed/workspaces/local.yaml"');
expect(smoke).not.toContain('workspace-content/local');
expect(smoke).toContain('core_remote="/fixtures/offline.git"');
expect(smoke).toContain('"degraded":true');
expect(smoke).toContain('compose-config-contract)');
expect(smoke).toContain('cleanup-failure-path)');
expect(unified).toContain('--fixtures-only');
expect(unified).toContain('thoth-workspaces.yaml');
expect(unified).toContain('$TASK13_SEED/$TASK13_WORKSPACE_ID/workspace.yaml');
expect(unified).toContain('$TASK13_SEED/$TASK13_WORKSPACE_ID/evidence/guide.md');
expect(unified).not.toContain('"$TASK13_SEED/workspaces/task13-smoke.yaml"');
expect(unified).not.toContain('workspace-content/task13-smoke');
expect(windows).toContain('thoth-workspaces.yaml');
expect(windows).toContain('$workspaceDestination = Join-Path $workspaceDirectory "workspace.yaml"');
expect(windows).toContain('Join-Path $workspaceEvidence "guide.md"');
expect(windows).toContain('legacy flat descriptor path must not be used');
expect(windows).not.toContain('workspace-content');
});
test("shared deployment fixtures remain valid standalone descriptors with canonical filesystem Evidence", () => {
const smoke = parseWorkspaceYaml(readFixture("workspace-registry-smoke.yaml"));
const task13 = parseWorkspaceYaml(readFixture("workspace-registry-task13.yaml"));
const windows = parseWorkspaceYaml(readFixture("workspace-registry-windows.yaml"));
expect(smoke).toMatchObject({
workspace: {
schema_version: 4,
id: "local",
name: "Local",
description: "Isolated workspace registry smoke fixture.",
},
evidence: {
source: { type: "filesystem", uri: "local/evidence", patterns: ["**/*.md"] },
policy: { max_chunk_chars: 4000, retain_published_generations: 3 },
},
});
expect(task13).toMatchObject({
workspace: { schema_version: 4, id: "task13-smoke", name: "Task 13 Smoke" },
evidence: {
source: { type: "filesystem", uri: "task13-smoke/evidence", patterns: ["**/*.md"] },
policy: { max_chunk_chars: 4000, retain_published_generations: 3 },
},
});
expect(windows).toMatchObject({
workspace: { schema_version: 4, id: "task13-windows", name: "Task 13 Windows" },
evidence: {
source: { type: "filesystem", uri: "task13-windows/evidence", patterns: ["**/*.md"] },
policy: { max_chunk_chars: 4000, retain_published_generations: 3 },
},
});
});
test("registry boots from the nested catalog layout, accepts co-committed display metadata, and advances on evidence-only commits", async () => {
const workspace = parseWorkspaceYaml(readFixture("workspace-registry-smoke.yaml"));
const fixture = await createRegistryFixture(workspace);
const bootstrapped = await fixture.registry.bootstrap();
expect(bootstrapped.head).toMatch(/^[0-9a-f]{40}$/);
expect(await fixture.registry.listCatalog()).toContainEqual(
expect.objectContaining({
id: "local",
name: "Local",
description: "Isolated workspace registry smoke fixture.",
configurationState: "ready",
}),
);
const coCommitted = workspaceVariant(workspace, { name: "Local Updated" });
writeFileSync(
join(fixture.source, "local", "workspace.yaml"),
serializeWorkspaceYaml(coCommitted),
);
writeFileSync(join(fixture.source, "thoth-workspaces.yaml"), catalogYaml(coCommitted));
const metadataCommit = await commitAll(fixture.source, "Update catalog and descriptor together");
const metadataStatus = await fixture.registry.pull();
expect(metadataStatus.head).toBe(metadataCommit);
const metadataRevision = await fixture.registry.read("local");
expect(metadataRevision.workspace.workspace.name).toBe("Local Updated");
writeFileSync(join(fixture.source, "local", "evidence", "guide.md"), "guide v2\n");
const evidenceCommit = await commitAll(fixture.source, "Update curated evidence only");
const evidenceStatus = await fixture.registry.pull();
expect(evidenceStatus.head).toBe(evidenceCommit);
expect(evidenceStatus.head).not.toBe(metadataCommit);
const evidenceRevision = await fixture.registry.read("local");
expect(evidenceRevision.workspace.workspace.name).toBe("Local Updated");
expect(evidenceRevision.revision.commit).toBe(evidenceCommit);
expect(evidenceRevision.revision.commit).not.toBe(metadataRevision.revision.commit);
expect(evidenceRevision.revision.blob).toBe(metadataRevision.revision.blob);
});
test("registry rejects orphan descriptors, metadata mismatches, and the retired flat layout while keeping the last active snapshot", async () => {
const workspace = parseWorkspaceYaml(readFixture("workspace-registry-smoke.yaml"));
const expectRejectedMutation = async (mutate: (fixture: Awaited<ReturnType<typeof createRegistryFixture>>) => Promise<void> | void) => {
const fixture = await createRegistryFixture(workspace);
await fixture.registry.bootstrap();
const active = await fixture.registry.read("local");
await mutate(fixture);
await commitAll(fixture.source, "Apply invalid registry mutation");
await expect(fixture.registry.pull()).rejects.toMatchObject({ code: "workspace_invalid" });
const retained = await fixture.registry.read("local");
expect(retained.revision.commit).toBe(active.revision.commit);
expect(retained.workspace.workspace.name).toBe("Local");
};
await expectRejectedMutation((fixture) => {
const mismatched = workspaceVariant(workspace, { name: "Local Mismatched" });
writeFileSync(join(fixture.source, "local", "workspace.yaml"), serializeWorkspaceYaml(mismatched));
});
await expectRejectedMutation((fixture) => {
const orphan = workspaceVariant(workspace, { id: "orphan", name: "Orphan Workspace" });
mkdirSync(join(fixture.source, "orphan", "evidence"), { recursive: true });
writeFileSync(join(fixture.source, "orphan", "workspace.yaml"), serializeWorkspaceYaml(orphan));
writeFileSync(join(fixture.source, "orphan", "evidence", "guide.md"), "orphan guide\n");
});
await expectRejectedMutation((fixture) => {
mkdirSync(join(fixture.source, "workspaces"), { recursive: true });
mkdirSync(join(fixture.source, "workspace-content", "local", "evidence"), { recursive: true });
writeFileSync(join(fixture.source, "workspaces", "local.yaml"), serializeWorkspaceYaml(workspace));
writeFileSync(join(fixture.source, "workspace-content", "local", "evidence", "guide.md"), "legacy guide\n");
});
});
test("Windows clone contract copies the shared complete schema v4 descriptor into the nested registry layout", () => {
const descriptor = parseWorkspaceYaml(readFixture("workspace-registry-windows.yaml"));
const windows = readFileSync(
new URL("../../scripts/test-windows-clone-contract.ps1", import.meta.url),
"utf8",
);
expect(windows).toContain('"scripts/fixtures/workspace-registry-windows.yaml"');
expect(windows).toContain('thoth-workspaces.yaml');
expect(windows).toContain('$workspaceDestination = Join-Path $workspaceDirectory "workspace.yaml"');
expect(windows).toContain('Join-Path $workspaceEvidence "guide.md"');
expect(windows).toContain('THT_AUTH_CONFIG_ROOT=$authConfigRoot');
expect(windows).toContain('"catalog-db,core,embedding,embedding-model-init,frontend,qdrant"');
expect(windows).toContain('"catalog-db,core,embedding,frontend,qdrant"');
expect(windows).not.toContain('schema_version: 3');
expect(descriptor).toMatchObject({
workspace: {
schema_version: 4,
id: "task13-windows",
name: "Task 13 Windows",
language: "en",
},
evidence: {
source: { type: "filesystem", uri: "task13-windows/evidence", patterns: ["**/*.md"] },
policy: { max_chunk_chars: 4000, retain_published_generations: 3 },
},
});
});
test("workspace registry smoke image cleanup is scoped to the per-run image identity", () => {
const output = execFileSync("bash", ["scripts/workspace-registry-smoke.sh"], {
cwd: new URL("../..", import.meta.url),
env: { ...process.env, WORKSPACE_REGISTRY_SMOKE_SELF_TEST: "image-cleanup-identity" },
encoding: "utf8",
});
expect(output).toContain("workspace registry smoke image cleanup identity self-test passed");
});
test("workspace registry smoke cleanup failure-path self-test preserves status and retention", () => {
const output = execFileSync("bash", ["scripts/workspace-registry-smoke.sh"], {
cwd: new URL("../..", import.meta.url),
env: { ...process.env, WORKSPACE_REGISTRY_SMOKE_SELF_TEST: "cleanup-failure-path" },
encoding: "utf8",
});
expect(output).toContain("workspace registry smoke cleanup failure-path self-test passed");
});
test("workspace migration source modules are absent from the live backend boundary", () => {
expect(existsSync(new URL("../src/workspaces/migrate-legacy.ts", import.meta.url))).toBe(false);
expect(existsSync(new URL("../src/workspaces/migrate-v2-qdrant.ts", import.meta.url))).toBe(false);
});