test: cover the P1.1 registry deployment contract
This commit is contained in:
@@ -1,15 +1,141 @@
|
||||
import { execFileSync } from "node:child_process";
|
||||
import { existsSync, readFileSync } from "node:fs";
|
||||
import { expect, test } from "vitest";
|
||||
import { parseWorkspaceYaml } from "../src/workspaces/schema.js";
|
||||
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";
|
||||
|
||||
test("declares a durable isolated registry volume and only read-only Git credential mounts", () => {
|
||||
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 },
|
||||
semantic_index: {
|
||||
...workspace.semantic_index,
|
||||
vector_store: { ...workspace.semantic_index.vector_store, collection: 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");
|
||||
@@ -23,123 +149,163 @@ test("declares a durable isolated registry volume and only read-only Git credent
|
||||
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('core_remote="/fixtures/remote.git"');
|
||||
expect(smoke).toContain(
|
||||
'cp "$root/scripts/fixtures/workspace-registry-smoke.yaml" "$seed/workspaces/local.yaml"',
|
||||
);
|
||||
expect(smoke).not.toMatch(/npm\s+--prefix\s+[^\n]*backend[^\n]*\srun\s+build/);
|
||||
expect(smoke).not.toMatch(/migrate-(?:legacy|v2-qdrant)/);
|
||||
expect(smoke).toContain("<<'COMPOSE_YAML'");
|
||||
expect(smoke).toContain('context: "${SMOKE_ROOT:?}"');
|
||||
expect(smoke).toContain('image: "${SMOKE_IMAGE:?}"');
|
||||
expect(smoke).toContain('THT_WORKSPACE_GIT_REMOTE: "${SMOKE_CORE_REMOTE:?}"');
|
||||
expect(smoke).toContain('THT_WORKSPACE_GIT_BRANCH: "${SMOKE_BRANCH:?}"');
|
||||
expect(smoke).toContain('source: "${SMOKE_REMOTE:?}"');
|
||||
expect(smoke).toContain("type: bind");
|
||||
expect(smoke).toContain("read_only: true");
|
||||
expect(smoke).not.toContain("context: $root");
|
||||
expect(smoke).not.toContain("image: $image");
|
||||
expect(smoke).not.toContain("- $remote:/fixtures/remote.git:ro");
|
||||
expect(smoke).toContain("compose-config-contract)");
|
||||
expect(smoke).toContain("cleanup-failure-path)");
|
||||
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 workspace registry smoke fixture parses as schema v3 internal semantic identity", () => {
|
||||
const source = readFileSync(
|
||||
new URL("../../scripts/fixtures/workspace-registry-smoke.yaml", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
const descriptor = parseWorkspaceYaml(source);
|
||||
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(descriptor).toMatchObject({
|
||||
workspace: { schema_version: 3, id: "local", name: "Local" },
|
||||
dwh: {
|
||||
engine: "postgres",
|
||||
database: "postgres",
|
||||
schema: "public",
|
||||
supported_transports: ["postgres_direct", "rest_api"],
|
||||
expect(smoke).toMatchObject({
|
||||
workspace: {
|
||||
schema_version: 3,
|
||||
id: "local",
|
||||
name: "Local",
|
||||
description: "Isolated workspace registry smoke fixture.",
|
||||
},
|
||||
semantic_index: {
|
||||
vector_store: {
|
||||
engine: "qdrant",
|
||||
collection: "local",
|
||||
dimensions: 1024,
|
||||
distance: "cosine",
|
||||
},
|
||||
embedding: {
|
||||
provider: "ollama_internal",
|
||||
model: "qwen3-embedding:0.6b",
|
||||
dimensions: 1024,
|
||||
},
|
||||
evidence: {
|
||||
source: { type: "filesystem", uri: "local/evidence", patterns: ["**/*.md"] },
|
||||
policy: { max_chunk_chars: 4000, retain_published_generations: 3 },
|
||||
},
|
||||
llm_policy: { default: "zai/glm-5.2", allowed: ["zai/glm-5.2"] },
|
||||
diagnostics: {
|
||||
dwh_rest: {
|
||||
method: "GET",
|
||||
path: "/health",
|
||||
auth: "none",
|
||||
response: { database: "database", schema: "schema" },
|
||||
},
|
||||
});
|
||||
expect(task13).toMatchObject({
|
||||
workspace: { schema_version: 3, 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: 3, 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 },
|
||||
},
|
||||
});
|
||||
expect(descriptor).not.toHaveProperty("evidence");
|
||||
});
|
||||
|
||||
test("Windows clone contract copies the shared complete schema v3 descriptor", () => {
|
||||
const fixture = readFileSync(
|
||||
new URL("../../scripts/fixtures/workspace-registry-windows.yaml", import.meta.url),
|
||||
"utf8",
|
||||
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 descriptor = parseWorkspaceYaml(fixture);
|
||||
|
||||
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 v3 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("Copy-Item -LiteralPath $workspaceFixture -Destination $workspaceDestination");
|
||||
expect(windows).not.toContain("schema_version:");
|
||||
expect(descriptor).toEqual({
|
||||
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).not.toContain('schema_version: 3');
|
||||
expect(descriptor).toMatchObject({
|
||||
workspace: {
|
||||
schema_version: 3,
|
||||
id: "task13-windows",
|
||||
name: "Task 13 Windows",
|
||||
language: "en",
|
||||
},
|
||||
dwh: {
|
||||
engine: "postgres",
|
||||
database: "warehouse",
|
||||
schema: "public",
|
||||
port: 5432,
|
||||
timeout_ms: 5000,
|
||||
supported_transports: ["postgres_direct", "rest_api"],
|
||||
},
|
||||
semantic_index: {
|
||||
vector_store: {
|
||||
engine: "qdrant",
|
||||
collection: "task13-windows",
|
||||
dimensions: 1024,
|
||||
distance: "cosine",
|
||||
},
|
||||
embedding: {
|
||||
provider: "ollama_internal",
|
||||
model: "qwen3-embedding:0.6b",
|
||||
dimensions: 1024,
|
||||
},
|
||||
},
|
||||
llm_policy: { default: "zai/glm-5.2", allowed: ["zai/glm-5.2"] },
|
||||
diagnostics: {
|
||||
dwh_rest: {
|
||||
method: "GET",
|
||||
path: "/health",
|
||||
auth: "none",
|
||||
response: { database: "database", schema: "schema" },
|
||||
},
|
||||
evidence: {
|
||||
source: { type: "filesystem", uri: "task13-windows/evidence", patterns: ["**/*.md"] },
|
||||
policy: { max_chunk_chars: 4000, retain_published_generations: 3 },
|
||||
},
|
||||
});
|
||||
expect(descriptor).not.toHaveProperty("evidence");
|
||||
});
|
||||
|
||||
test("workspace registry smoke image cleanup is scoped to the per-run image identity", () => {
|
||||
|
||||
Reference in New Issue
Block a user