334 lines
15 KiB
TypeScript
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);
|
|
});
|