2026-09-21 16:59:35 -07:00
|
|
|
import test from "node:test";
|
|
|
|
|
import assert from "node:assert/strict";
|
|
|
|
|
import fs from "node:fs/promises";
|
|
|
|
|
import os from "node:os";
|
|
|
|
|
import path from "node:path";
|
|
|
|
|
import { execFile as callback } from "node:child_process";
|
|
|
|
|
import { promisify } from "node:util";
|
2026-09-22 14:42:54 -07:00
|
|
|
import { componentEntries } from "../src/capability-language/component-nix.js";
|
2026-09-21 16:59:35 -07:00
|
|
|
import { scaffoldRecipe } from "../src/capability-language/scaffold-recipes.js";
|
|
|
|
|
import { planStructure, applyStructure } from "../src/capability-language/structural-plan.js";
|
|
|
|
|
const execFile = promisify(callback);
|
|
|
|
|
test("checked component scaffolding composes with execution handlers without replacing authored files", async (context) => {
|
|
|
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "qx-component-scaffold-"));
|
|
|
|
|
context.after(() => fs.rm(root, { recursive: true, force: true }));
|
|
|
|
|
await execFile("git", ["-C", root, "init"]);
|
|
|
|
|
const source = { repository: "https://example.test/component.git", commit: "a".repeat(40) };
|
|
|
|
|
const apply = async (
|
|
|
|
|
command: Parameters<typeof scaffoldRecipe>[1],
|
|
|
|
|
spec: Omit<Parameters<typeof scaffoldRecipe>[2], "source">,
|
|
|
|
|
) => applyStructure(await planStructure(root, await scaffoldRecipe(root, command, { ...spec, source })));
|
|
|
|
|
await apply("package", {
|
|
|
|
|
name: "Widgets",
|
|
|
|
|
id: "package:widgets",
|
|
|
|
|
revision: "package:widgets@1",
|
|
|
|
|
template: "typescript-component",
|
|
|
|
|
tools: { quixos: source, protocol: source, helpers: source, sdk: source },
|
|
|
|
|
});
|
|
|
|
|
const packageFile = path.join(root, "package.qx");
|
2026-09-22 13:16:23 -07:00
|
|
|
await fs.writeFile(packageFile, "import interface View;\n" + (await fs.readFile(packageFile, "utf8")));
|
2026-09-21 16:59:35 -07:00
|
|
|
await apply("component", {
|
|
|
|
|
name: "Card",
|
|
|
|
|
id: "component:card",
|
2026-09-22 13:16:23 -07:00
|
|
|
declaration: 'module Card id "component:card" implements View.render;',
|
2026-09-21 16:59:35 -07:00
|
|
|
});
|
2026-09-22 14:42:54 -07:00
|
|
|
assert.deepEqual(
|
|
|
|
|
{ ...(await componentEntries(await fs.readFile(path.join(root, "components.nix"), "utf8"))) },
|
|
|
|
|
{
|
|
|
|
|
"component:card": { entry: "src/components/Card.tsx", export: "Card" },
|
|
|
|
|
},
|
|
|
|
|
);
|
2026-09-21 16:59:35 -07:00
|
|
|
const card = path.join(root, "src/components/Card.tsx");
|
|
|
|
|
await fs.appendFile(card, "\n// authored extension\n");
|
|
|
|
|
const retained = await fs.readFile(card, "utf8");
|
2026-09-22 14:42:54 -07:00
|
|
|
const flake = path.join(root, "flake.nix");
|
|
|
|
|
await fs.appendFile(flake, "\n# retained authored toolchain configuration\n");
|
|
|
|
|
const retainedFlake = await fs.readFile(flake, "utf8");
|
|
|
|
|
const entriesFile = path.join(root, "components.nix");
|
|
|
|
|
await fs.writeFile(
|
|
|
|
|
entriesFile,
|
|
|
|
|
(await fs.readFile(entriesFile, "utf8")).replace("{\n", "{\n # retained entry comment\n"),
|
|
|
|
|
);
|
2026-09-21 16:59:35 -07:00
|
|
|
await apply("function", {
|
|
|
|
|
name: "echo",
|
|
|
|
|
id: "echo",
|
|
|
|
|
declaration: 'function echo id "echo" : string -> string execution snapshot targets [browser,server];',
|
|
|
|
|
});
|
|
|
|
|
await apply("component", {
|
|
|
|
|
name: "Details",
|
|
|
|
|
id: "component:details",
|
2026-09-22 13:16:23 -07:00
|
|
|
declaration: 'module Details id "component:details" implements View.render;',
|
2026-09-21 16:59:35 -07:00
|
|
|
});
|
|
|
|
|
assert.equal(await fs.readFile(card, "utf8"), retained);
|
2026-09-22 14:42:54 -07:00
|
|
|
assert.equal(await fs.readFile(flake, "utf8"), retainedFlake);
|
|
|
|
|
assert.match(await fs.readFile(entriesFile, "utf8"), /retained entry comment/);
|
2026-09-21 16:59:35 -07:00
|
|
|
assert.match(await fs.readFile(path.join(root, "src/server.ts"), "utf8"), /echo/);
|
2026-09-23 00:17:13 -07:00
|
|
|
assert.match(await fs.readFile(path.join(root, "flake.nix"), "utf8"), /mkCaminoReactPackage/);
|
2026-09-21 16:59:35 -07:00
|
|
|
await assert.rejects(
|
|
|
|
|
apply("component", {
|
|
|
|
|
name: "Card",
|
|
|
|
|
id: "component:other",
|
2026-09-22 13:16:23 -07:00
|
|
|
declaration: 'module Card id "component:other" implements View.render;',
|
2026-09-21 16:59:35 -07:00
|
|
|
}),
|
|
|
|
|
/unique/,
|
|
|
|
|
);
|
|
|
|
|
await assert.rejects(
|
|
|
|
|
apply("component", {
|
|
|
|
|
name: "Injected",
|
|
|
|
|
id: "component:inject",
|
|
|
|
|
declaration:
|
2026-09-22 13:16:23 -07:00
|
|
|
'module Injected id "component:inject" implements View.render; function sneak id "sneak" : unit -> unit;',
|
2026-09-21 16:59:35 -07:00
|
|
|
}),
|
|
|
|
|
/exactly one/,
|
|
|
|
|
);
|
2026-09-22 14:42:54 -07:00
|
|
|
const beforeUnsupported = await fs.readFile(packageFile, "utf8");
|
|
|
|
|
await fs.writeFile(entriesFile, '{\n custom = builtins.abort "must not evaluate";\n}\n');
|
|
|
|
|
await assert.rejects(
|
|
|
|
|
apply("component", {
|
|
|
|
|
name: "Unsupported",
|
|
|
|
|
id: "component:unsupported",
|
|
|
|
|
declaration: 'module Unsupported id "component:unsupported" implements View.render;',
|
|
|
|
|
}),
|
|
|
|
|
/literal-entry shape/,
|
|
|
|
|
);
|
|
|
|
|
assert.equal(await fs.readFile(packageFile, "utf8"), beforeUnsupported);
|
2026-09-21 16:59:35 -07:00
|
|
|
});
|
|
|
|
|
|
|
|
|
|
test("controller scaffolding preserves existing registrations and rejects duplicate identities", async (context) => {
|
|
|
|
|
const root = await fs.mkdtemp(path.join(os.tmpdir(), "qx-controller-scaffold-"));
|
|
|
|
|
context.after(() => fs.rm(root, { recursive: true, force: true }));
|
|
|
|
|
await execFile("git", ["-C", root, "init"]);
|
|
|
|
|
const source = { repository: "https://example.test/adapter.git", commit: "a".repeat(40) };
|
|
|
|
|
const apply = async (
|
|
|
|
|
command: Parameters<typeof scaffoldRecipe>[1],
|
|
|
|
|
spec: Omit<Parameters<typeof scaffoldRecipe>[2], "source">,
|
|
|
|
|
) => applyStructure(await planStructure(root, await scaffoldRecipe(root, command, { ...spec, source })));
|
|
|
|
|
await apply("package", {
|
|
|
|
|
name: "Adapter",
|
|
|
|
|
id: "adapter",
|
|
|
|
|
revision: "adapter@1",
|
|
|
|
|
tools: { quixos: source, protocol: source, helpers: source },
|
|
|
|
|
});
|
|
|
|
|
const controller = {
|
|
|
|
|
subject: "display@1",
|
|
|
|
|
snapshot: "display:requested",
|
|
|
|
|
remote: "display:apply",
|
|
|
|
|
input: "value" as const,
|
|
|
|
|
};
|
|
|
|
|
await apply("controller", { id: "display:controller", controller });
|
|
|
|
|
await apply("controller", { id: "display:monitor", controller: { ...controller, snapshot: "display:status" } });
|
|
|
|
|
const entries = JSON.parse(await fs.readFile(path.join(root, "quixos.controllers.json"), "utf8")).controllers;
|
|
|
|
|
assert.equal(entries.length, 2);
|
|
|
|
|
assert.deepEqual(entries[0], { id: "display:controller", ...controller });
|
|
|
|
|
await assert.rejects(apply("controller", { id: "display:controller", controller }), /duplicate/);
|
|
|
|
|
});
|