Reuse the runtime catalog and workspace parsers in a standalone helper paired with tht. Add document templates, safe diagnostics, local Evidence checks, native bundle builds, shared CLI fixtures and IT/EN preparation guides. Record the approved document-first specification and ticket breakdown. Refs #43.
120 lines
7.7 KiB
TypeScript
120 lines
7.7 KiB
TypeScript
import { spawnSync } from "node:child_process";
|
|
import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync, symlinkSync } from "node:fs";
|
|
import { tmpdir } from "node:os";
|
|
import { join, resolve } from "node:path";
|
|
import { afterEach, expect, test } from "vitest";
|
|
import { parseWorkspaceCatalogYaml } from "../src/workspaces/catalog.js";
|
|
import { parseWorkspaceYaml } from "../src/workspaces/schema.js";
|
|
|
|
const roots: string[] = [];
|
|
function directory() {
|
|
const root = mkdtempSync(join(tmpdir(), "tht-documents-"));
|
|
roots.push(root);
|
|
return join(root, "workspaces");
|
|
}
|
|
afterEach(() => roots.splice(0).forEach((root) => rmSync(root, { recursive: true, force: true })));
|
|
|
|
function cli(...args: string[]) {
|
|
const packaged = process.env.THT_WORKSPACE_TEST_CLI;
|
|
const result = spawnSync(packaged ?? process.execPath, [...(packaged ? ["workspace"] : ["--import", "tsx", resolve("src/workspace-documents-cli.ts")]), ...args, "--json"], {
|
|
encoding: "utf8", env: { ...process.env, PATH: "" },
|
|
});
|
|
return { ...result, report: result.stdout.trim() ? JSON.parse(result.stdout) : null };
|
|
}
|
|
|
|
const descriptor = "workspace:\n schema_version: 4\n id: practice\n name: Practice\n language: en\n";
|
|
const catalog = "schema_version: 1\nworkspaces:\n - id: practice\n name: Practice\n";
|
|
const corpus = [
|
|
{ name: "minimal", descriptor, catalog, valid: true },
|
|
{ name: "optional Evidence", descriptor: descriptor + "evidence:\n source:\n type: http\n uris: [https://example.com/manual.md]\n", catalog, valid: true },
|
|
{ name: "duplicate YAML key", descriptor: descriptor + " id: practice\n", catalog, valid: false },
|
|
{ name: "ambiguous document", descriptor: descriptor + "---\n" + descriptor, catalog, valid: false },
|
|
{ name: "unknown workspace field", descriptor: descriptor + " secret: VERY_SECRET_VALUE\n", catalog, valid: false },
|
|
{ name: "database binding in authored descriptor", descriptor: descriptor + "dwh: {password: VERY_SECRET_VALUE}\n", catalog, valid: false },
|
|
{ name: "duplicate catalog id", descriptor, catalog: catalog + " - id: practice\n name: Practice\n", valid: false },
|
|
{ name: "unknown catalog field", descriptor, catalog: catalog + "secret: VERY_SECRET_VALUE\n", valid: false },
|
|
{ name: "invalid catalog YAML", descriptor, catalog: catalog + "schema_version: 1\n", valid: false },
|
|
{ name: "unsafe Evidence URI", descriptor: descriptor + "evidence:\n source:\n type: http\n uris: [https://user:VERY_SECRET_VALUE@example.com/file]\n", catalog, valid: false },
|
|
];
|
|
|
|
test.each(corpus)("CLI and runtime agree: $name", (fixture) => {
|
|
const root = directory();
|
|
mkdirSync(join(root, "practice"), { recursive: true });
|
|
writeFileSync(join(root, "thoth-workspaces.yaml"), fixture.catalog);
|
|
writeFileSync(join(root, "practice/workspace.yaml"), fixture.descriptor);
|
|
let accepted = true;
|
|
try { parseWorkspaceCatalogYaml(fixture.catalog); parseWorkspaceYaml(fixture.descriptor); }
|
|
catch { accepted = false; }
|
|
expect(accepted).toBe(fixture.valid);
|
|
const checked = cli("validate", "--directory", root);
|
|
expect(checked.status).toBe(fixture.valid ? 0 : 1);
|
|
expect(checked.report.ok).toBe(accepted);
|
|
expect(checked.stdout + checked.stderr).not.toContain("VERY_SECRET_VALUE");
|
|
if (!fixture.valid) {
|
|
expect(checked.report.issues[0].document).not.toBe(".");
|
|
expect(checked.report.issues[0].correction.length).toBeGreaterThan(10);
|
|
}
|
|
});
|
|
|
|
test("prepare refuses existing directories and invalid options without changing documents", () => {
|
|
const root = directory();
|
|
expect(cli("prepare", "--directory", root, "--id", "practice", "--name", "Practice").status).toBe(0);
|
|
const before = readFileSync(join(root, "thoth-workspaces.yaml"), "utf8");
|
|
expect(cli("prepare", "--directory", root, "--id", "other", "--name", "Other").report.issues[0].code).toBe("destination_exists");
|
|
expect(readFileSync(join(root, "thoth-workspaces.yaml"), "utf8")).toBe(before);
|
|
expect(cli("validate", "--directory", root, "--typo", "VERY_SECRET_VALUE").status).toBe(2);
|
|
});
|
|
|
|
test("validation rejects directory mismatches, missing references and symlinks without following them", () => {
|
|
const root = directory();
|
|
cli("prepare", "--directory", root, "--id", "practice", "--name", "Practice");
|
|
mkdirSync(join(root, "unlisted"));
|
|
expect(cli("validate", "--directory", root).report.issues.some((i: {code: string}) => i.code === "unlisted_directory")).toBe(true);
|
|
rmSync(join(root, "unlisted"), { recursive: true });
|
|
writeFileSync(join(root, "practice/workspace.yaml"), descriptor.replace("name: Practice", "name: Different"));
|
|
expect(cli("validate", "--directory", root).report.issues[0].code).toBe("catalog_mismatch");
|
|
writeFileSync(join(root, "practice/workspace.yaml"), descriptor + "evidence:\n source:\n type: filesystem\n uri: practice/evidence\n");
|
|
expect(cli("validate", "--directory", root).report.issues[0].code).toBe("missing_reference");
|
|
symlinkSync(roots[roots.length - 1], join(root, "practice/evidence"), "dir");
|
|
expect(cli("validate", "--directory", root).report.issues[0].code).toBe("unsafe_reference");
|
|
});
|
|
|
|
test("local Evidence checks references and YAML frontmatter, and explicitly defers canonical content validation", () => {
|
|
const root = directory();
|
|
cli("prepare", "--directory", root, "--id", "practice", "--name", "Practice");
|
|
writeFileSync(join(root, "practice/workspace.yaml"), descriptor + "evidence:\n schema_version: 2\n source:\n type: filesystem\n uri: practice/evidence\n patterns: ['curated/**/*.md']\n");
|
|
mkdirSync(join(root, "practice/evidence/curated/domain"), { recursive: true });
|
|
const evidence = join(root, "practice/evidence/curated/domain/rule.md");
|
|
writeFileSync(evidence, "---\nschema_version: 4\nid: evidence:rule\nkind: domain\nlanguage: en\npurposes: [sql_generation]\n---\n# Rule\n\n## Rule\nUse the order number.\n");
|
|
const checked = cli("validate", "--directory", root);
|
|
expect(checked.status).toBe(0);
|
|
expect(checked.report.workspaces).toEqual([{ id: "practice", evidence: "local-files" }]);
|
|
expect(checked.report.deferred_checks).toContain("practice:evidence-content-provenance-and-indexing");
|
|
writeFileSync(evidence, "---\nid: first\nid: VERY_SECRET_VALUE\n---\n# Rule\n");
|
|
const invalid = cli("validate", "--directory", root);
|
|
expect(invalid.status).toBe(1);
|
|
expect(invalid.report.issues[0].document).toBe("practice/evidence/curated/domain/rule.md");
|
|
expect(invalid.stdout).not.toContain("VERY_SECRET_VALUE");
|
|
writeFileSync(join(root, "practice/workspace.yaml"), descriptor + "evidence:\n source:\n type: filesystem\n uri: practice/evidence\n patterns: [missing.md]\n");
|
|
expect(cli("validate", "--directory", root).report.issues[0].code).toBe("missing_reference");
|
|
});
|
|
|
|
test("prepare creates documents accepted by the runtime and validate is repeatable without installation or services", () => {
|
|
const root = directory();
|
|
const prepared = cli("prepare", "--directory", root, "--id", "practice", "--name", "Practice");
|
|
expect(prepared.stderr).toBe("");
|
|
expect(prepared.status).toBe(0);
|
|
expect(prepared.report.ok).toBe(true);
|
|
const catalog = readFileSync(join(root, "thoth-workspaces.yaml"), "utf8");
|
|
const descriptor = readFileSync(join(root, "practice/workspace.yaml"), "utf8");
|
|
expect(parseWorkspaceCatalogYaml(catalog).workspaces[0].id).toBe("practice");
|
|
expect(parseWorkspaceYaml(descriptor).evidence).toBeUndefined();
|
|
for (let index = 0; index < 2; index++) {
|
|
const checked = cli("validate", "--directory", root);
|
|
expect(checked.status).toBe(0);
|
|
expect(checked.report.workspaces).toEqual([{ id: "practice", evidence: "absent" }]);
|
|
}
|
|
expect(readFileSync(join(root, "thoth-workspaces.yaml"), "utf8")).toBe(catalog);
|
|
expect(readFileSync(join(root, "practice/workspace.yaml"), "utf8")).toBe(descriptor);
|
|
});
|