pi-mono/packages/chord/test/next-node/app/support.ts
Mario Zechner 5bc4145165 feat(chord): Chord v2 web and terminal surfaces (slice 11)
- Web: base bundles built from the app's entry at start (content-hashed, served
  forever), plugin scripts that register themselves and one combined file,
  WebScripts/TuiScripts showing the current set, startPage with the connection
  loop, in-place plugin reloads and a reload on a new base bundle.
- React bindings: HostProvider, useHold, useWatch, useRegistry, useRefState.
- Terminal: startTerminal, a launcher that signs in with a token, a token file or
  device pairing and always runs the app's terminal code in a sandboxed child
  process (pipes, empty environment, --permission granting nothing, no SQLite,
  a builtin allowlist and a frozen process inside); output filter, confirmed
  copy/open/attach actions; startTerminalRuntime and TuiScope inside.
- Themes: the Themes registry, ThemeChoice per principal, CSS variables on the
  page, terminal colours by role; CSS modules in plugin builds.
- App kit: startServer gains web and tui; startApp renamed startServer,
  exportAddress/importAddress renamed exportData/importData and export no longer
  stops the worker; ephemeral sessions end with their client's last link.
- Examples 56-web, 57-tui, 58-themes, 59-chat.
2026-11-02 21:31:59 +00:00

187 lines
7.8 KiB
TypeScript

// Test support for the app kit (slice 13): an app started in a temporary data directory with a temporary plugins root,
// the tests' API (Chord's surface as `@test/app/v1`), a worker kind whose entry is the fixture worker, and requests to
// it as a browser or a command-line client would make them.
import { mkdtemp, rm } from "node:fs/promises";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { fileURLToPath } from "node:url";
import { afterEach } from "vitest";
import { BACKGROUND_CONTEXT as context } from "../../../src/context/index.ts";
import { hostKind, int, method, remote, str, unknown } from "../../../src/next/contracts/index.ts";
import * as chordNext from "../../../src/next/index.ts";
import {
type App,
AppInfo,
type AppOptions,
Files,
Sessions,
startServer,
WorkerInfo,
} from "../../../src/next-node/app/index.ts";
import { Http } from "../../../src/next-node/http/index.ts";
import { writeFiles, writePlugin } from "../plugins/support.ts";
/** The principals of these tests. */
export type TestPrincipal =
| { readonly kind: "server" }
| { readonly kind: "owner" }
| { readonly kind: "worker"; readonly key: string }
| { readonly kind: "user"; readonly id: string; readonly name: string };
/** The tests' worker kind: a project by id. */
export const Project = hostKind("project", { id: str() });
/** The tests' API v1: Chord's surface and the kit's objects, as an app's API re-exports them. */
export const API_V1 = { ...chordNext, AppInfo, WorkerInfo, Files, Sessions };
/** The tests' API, as the server and the fixture worker have it. */
export const API = {
name: "@test/app",
versions: ["v1"],
modules: { "@test/app/v1": API_V1, "@test/app/v1/server": { Http } },
} as const;
/** The fixture worker's entry. */
export const WORKER_ENTRY = fileURLToPath(new URL("../fixtures/app/worker.ts", import.meta.url));
const made: string[] = [];
afterEach(async () => {
for (const dir of made.splice(0)) await rm(dir, { recursive: true, force: true });
});
/** A temporary directory removed after the test. */
export async function tempDir(): Promise<string> {
const dir = await mkdtemp(join(tmpdir(), "chord-app-"));
made.push(dir);
return dir;
}
/** A started test app and what it reported; `plugins` (directory name to files) written into its root first. */
export async function testApp(
options: Partial<AppOptions<TestPrincipal>> & {
readonly plugins?: Readonly<Record<string, Readonly<Record<string, string>>>>;
readonly withWorkers?: boolean;
} = {},
): Promise<{ app: App<TestPrincipal>; errors: unknown[]; dataDir: string; root: string; port: number }> {
const base = await tempDir();
const dataDir = options.dataDir ?? join(base, "data");
const root = options.root ?? join(base, "plugins");
await writeFiles(root, { ".keep": "" });
for (const [dir, files] of Object.entries(options.plugins ?? {}))
await writePlugin(root, dir, `@test/${dir}`, files);
const errors: unknown[] = [];
const app = await startServer<TestPrincipal>(
{
name: "chord-test",
dataDir,
root,
listen: { host: "127.0.0.1", port: 0 },
api: API,
kinds: { server: { platform: "node" }, worker: { platform: "node" } },
principal: { server: { kind: "server" }, worker: (address) => ({ kind: "worker", key: address.key }) },
workers:
options.withWorkers === true
? [
{
kind: Project,
entry: WORKER_ENTRY,
execArgv: ["--conditions=source", "--experimental-strip-types", "--no-warnings"],
start: (address) => ({ project: address.key }),
authorize: () => true,
idleMs: () => 60_000,
},
]
: [],
onError: (error) => errors.push(error),
...options,
},
context,
);
const port = Number(new URL(app.ownerLink()).port);
return { app, errors, dataDir, root, port };
}
/** A plugin's server facet serving `Echo` (a `local` type) through the tests' API. */
export const ECHO_SERVER = [
'import { constant, defineFacet, local, str } from "@test/app/v1";',
'const Echo = local("test.echo", { version: 1, params: {}, members: { text: str() } });',
'export default defineFacet((host) => host.serve(Echo, () => ({ text: constant("echo") })));',
].join("\n");
/** The notes plugin's remote contract (`NOTES_PLUGIN`), declared again for the tests (contracts are data). */
export const Notes = remote("test.notes", {
version: 1,
params: { id: str() },
hostAddress: ({ id }) => Project({ id }),
variants: {
main: {
state: { text: str(), start: unknown() },
methods: {
write: method(str()),
visit: method(str(), int()),
attach: method(str(), str()),
file: method(str(), str()),
},
},
},
});
/**
* The notes plugin of the export tests (`withWorkers`): on each project's worker a store object with a persisted
* document, a table and the worker's files, reached from the server through the remote `Notes`.
*/
export const NOTES_PLUGIN: Readonly<Record<string, string>> = {
"contract.ts": [
'import { hostKind, int, method, remote, str, unknown } from "@test/app/v1";',
'export const Project = hostKind("project", { id: str() });',
'export const Notes = remote("test.notes", {',
"\tversion: 1,",
"\tparams: { id: str() },",
"\thostAddress: ({ id }) => Project({ id }),",
"\tvariants: { main: { state: { text: str(), start: unknown() }, methods: {",
"\t\twrite: method(str()), visit: method(str(), int()), attach: method(str(), str()), file: method(str(), str()),",
"\t} } },",
"});",
].join("\n"),
"worker.ts": [
'import { defineFacet, defineState, defineTable, Files, int, local, method, obj, str, WorkerInfo } from "@test/app/v1";',
'import { Notes } from "./contract.ts";',
'const Text = defineState("test.text", { schema: obj({ text: str() }), version: 1, initial: () => ({ text: "" }), persist: true, checkpointWhen: () => false });',
'const Visits = defineTable("test.visits", { version: 1, columns: { page: str(), count: int() }, key: ["page"] });',
'const Store = local("test.store", { version: 1, params: {}, members: { text: str(), write: method(str()), visit: method(str(), int()) } });',
"export default defineFacet((host) => {",
"\thost.serve(Store, (_params, self) => {",
"\t\tconst text = self.state(Text);",
"\t\tconst visits = self.table(Visits);",
"\t\treturn {",
"\t\t\ttext: self.derive([text], ([value]) => value.text),",
"\t\t\twrite: async (next, ctx) => { await text.change((draft) => { draft.text = next; }, ctx); return null; },",
"\t\t\tvisit: async (page, ctx) => self.tx(async (tx) => { const count = ((await tx.get(visits, { page }))?.count ?? 0) + 1; tx.put(visits, { page, count }); return count; }, ctx),",
"\t\t};",
"\t});",
"\thost.serve(Notes, (_params, _principal, self) => {",
"\t\tconst store = self.get(Store, {});",
"\t\tconst info = self.get(WorkerInfo, {});",
"\t\tconst files = self.get(Files, {});",
"\t\tconst decoder = new TextDecoder();",
'\t\treturn { variant: "main", members: {',
"\t\t\ttext: self.derive([store.text], ([value]) => value),",
"\t\t\tstart: self.derive([info.start], ([value]) => value),",
"\t\t\twrite: (next, ctx) => store.write(next, ctx),",
"\t\t\tvisit: (page, ctx) => store.visit(page, ctx),",
'\t\t\tattach: (body, ctx) => files.put({ name: "note.txt", mime: "text/plain", bytes: new TextEncoder().encode(body) }, ctx),',
"\t\t\tfile: async (id, ctx) => decoder.decode(await files.read({ id, offset: 0, length: 1024 }, ctx)),",
"\t\t} };",
"\t});",
"});",
].join("\n"),
};
/** A plugin's server facet with a route (not public) answering who it runs as: `GET /whoami`. */
export const WHOAMI = [
'import { defineFacet } from "@test/app/v1";',
'import { Http } from "@test/app/v1/server";',
'export default defineFacet((_host, self) => self.contribute(Http, { routes: { "GET /whoami": async (_request, ctx) => ({ kind: "json", value: ctx.principal ?? null }) } }));',
].join("\n");