mirror of
https://github.com/badlogic/pi-mono.git
synced 2026-11-05 23:37:25 +00:00
docs(chord): the Chord v2 tutorial, a todo app built with the app kit (example 61)
This commit is contained in:
parent
0c7c741988
commit
24d3993b20
31 changed files with 3768 additions and 0 deletions
1341
packages/chord/docs/tutorial/book.md
Normal file
1341
packages/chord/docs/tutorial/book.md
Normal file
File diff suppressed because it is too large
Load diff
1378
packages/chord/docs/tutorial/index.html
Normal file
1378
packages/chord/docs/tutorial/index.html
Normal file
File diff suppressed because it is too large
Load diff
BIN
packages/chord/docs/tutorial/screens/edited.png
Normal file
BIN
packages/chord/docs/tutorial/screens/edited.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
BIN
packages/chord/docs/tutorial/screens/harbor.png
Normal file
BIN
packages/chord/docs/tutorial/screens/harbor.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
BIN
packages/chord/docs/tutorial/screens/page.png
Normal file
BIN
packages/chord/docs/tutorial/screens/page.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"name": "@todo/harbor",
|
||||
"private": true,
|
||||
"type": "module"
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
// The Harbor theme's data, shared by its web and terminal facets: colours by role, a corner radius for the page, a
|
||||
// 256-colour muted tone for terminals.
|
||||
|
||||
import type { Theme } from "@todo/app/v1";
|
||||
|
||||
export const harbor: Theme = {
|
||||
id: "harbor",
|
||||
name: "Harbor",
|
||||
mode: "dark",
|
||||
colors: {
|
||||
accent: "#f2a65a",
|
||||
bg: "#12232e",
|
||||
fg: "#d8e2e8",
|
||||
muted: "#6b8796",
|
||||
error: "#e5676b",
|
||||
warning: "#e9c46a",
|
||||
success: "#7fb685",
|
||||
},
|
||||
web: { radius: "6px" },
|
||||
tui: { muted: "66" },
|
||||
};
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
// The Harbor theme in terminals.
|
||||
|
||||
import { defineFacet, Themes } from "@todo/app/v1";
|
||||
import { harbor } from "./theme.ts";
|
||||
|
||||
export default defineFacet((_host, self) => self.contribute(Themes, harbor));
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
// The Harbor theme on pages.
|
||||
|
||||
import { defineFacet, Themes } from "@todo/app/v1";
|
||||
import { harbor } from "./theme.ts";
|
||||
|
||||
export default defineFacet((_host, self) => self.contribute(Themes, harbor));
|
||||
249
packages/chord/test/examples/61-tutorial/main.ts
Normal file
249
packages/chord/test/examples/61-tutorial/main.ts
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
// The tutorial's app (`docs/tutorial/book.md`): a shared todo app built with the app kit, one worker per list. Each
|
||||
// numbered step is the tutorial's chapter of that number: the server and the owner's sign-in, a list on its worker,
|
||||
// routes, the page, the terminal, a theme plugin added while the app runs, a second user, an edit applied in place,
|
||||
// and a list exported and imported. The lists plugin's own test is `plugins/lists/worker.test.ts`; `screenshots.ts`
|
||||
// shows the page in Chromium.
|
||||
// Run from packages/chord (with Node 26.5.1 or later the terminal client runs too; `--serve` keeps the app running):
|
||||
// node --conditions=source --experimental-strip-types test/examples/61-tutorial/main.ts
|
||||
import { cp, readdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { PassThrough } from "node:stream";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { WebSocket } from "ws";
|
||||
import { BACKGROUND_CONTEXT as context } from "../../../src/context/index.ts";
|
||||
import { type ChordError, createHost, WebScripts, type WebSocketLike, webSocketLink } from "../../../src/index.ts";
|
||||
import { type RunningTerminal, requireSandboxNode, startTerminal } from "../../../src/node/tui/index.ts";
|
||||
import { type Item, List, Todos } from "./src/api/v1/index.ts";
|
||||
import type { TodoPrincipal } from "./src/principals.ts";
|
||||
import { startTodo, todoDirectories } from "./start.ts";
|
||||
|
||||
const { base, dataDir, root } = await todoDirectories();
|
||||
|
||||
// 1. Start the app: the kit makes the data directory, the server's store and host, HTTP and the owner link. Nobody is
|
||||
// signed in yet, so the page answers 401. The owner opens the link: its page posts the token from the URL's fragment
|
||||
// and gets a session cookie.
|
||||
const app = await startTodo(dataDir, root, context);
|
||||
const link = new URL(app.ownerLink());
|
||||
const url = link.origin;
|
||||
console.log("1. owner link:", `http://127.0.0.1:<port>${link.pathname}#<token>`);
|
||||
console.log(" data directory:", (await readdir(dataDir)).sort().join(", "));
|
||||
console.log(" GET / signed out:", (await fetch(url)).status);
|
||||
const signIn = await fetch(`${url}/_chord/owner`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ token: link.hash.slice(1) }),
|
||||
});
|
||||
const cookie = signIn.headers.get("set-cookie")!.split(";")[0]!;
|
||||
console.log(" the owner signs in:", signIn.status, cookie.replace(/=.*/, "=…"));
|
||||
console.log(" GET / signed in:", (await fetch(url, { headers: { cookie } })).status);
|
||||
|
||||
// 2. A list on its own worker. Code acting as the owner gets the groceries list's `Todos`: the request goes to the
|
||||
// list's address, and the first one starts its worker. The same call in a root of the server acts as the server.
|
||||
const OWNER = { kind: "owner" } as const;
|
||||
await app.host.root(
|
||||
async (ctx) => {
|
||||
const todos = await ctx.get(Todos, { list: "groceries" });
|
||||
await todos.add("milk", ctx);
|
||||
await todos.add("eggs", ctx);
|
||||
await todos.tick({ id: 1, done: true }, ctx);
|
||||
await until(() => todos.items.value[0]?.done === true);
|
||||
console.log("2. as the owner:", todos.variant, "|", show(todos.items.value));
|
||||
},
|
||||
context,
|
||||
{ principal: OWNER },
|
||||
);
|
||||
await app.host.root(async (ctx) => {
|
||||
const todos = await ctx.get(Todos, { list: "groceries" });
|
||||
await todos.add("bread", ctx);
|
||||
await until(() => todos.items.value.length === 3);
|
||||
console.log(" as the server:", todos.variant, "|", show(todos.items.value.slice(-1)));
|
||||
}, context);
|
||||
const workers = join(dataDir, "workers", "list");
|
||||
const [groceriesDir] = await readdir(workers);
|
||||
console.log(" workers:", Object.keys(app.workers.get("list")!.state.value).join(", "));
|
||||
console.log(` workers/list/${groceriesDir}:`, (await readdir(join(workers, groceriesDir!))).sort().join(", "));
|
||||
|
||||
// 3. Routes. A command-line client on this machine sends the owner token file as a bearer token; the route's
|
||||
// `ctx.get` acts as the request's principal, so the todo is the owner's.
|
||||
const token = (await readFile(app.ownerTokenFile, "utf8")).trim();
|
||||
const cli = { authorization: `Bearer ${token}` };
|
||||
const posted = await fetch(`${url}/api/lists/groceries`, {
|
||||
method: "POST",
|
||||
headers: { ...cli, "content-type": "application/json" },
|
||||
body: JSON.stringify("butter"),
|
||||
});
|
||||
console.log("3. POST /api/lists/groceries:", await posted.json());
|
||||
const listed = (await (await fetch(`${url}/api/lists/groceries`, { headers: cli })).json()) as Item[];
|
||||
console.log(" GET /api/lists/groceries:", show(listed));
|
||||
console.log(" GET without a token:", (await fetch(`${url}/api/lists/groceries`)).status);
|
||||
|
||||
// 4. The page: the app's template with the base bundle, the plugins' combined script and their stylesheets. A watch
|
||||
// starts at the current value; its listener gets only what changes after.
|
||||
const html = await (await fetch(url, { headers: cli })).text();
|
||||
console.log("4. the page loads:");
|
||||
for (const [, asset] of html.matchAll(/(?:src|href)="([^"]+)"/g)) console.log(` ${hashless(asset!)}`);
|
||||
const groceries = await app.host.get(Todos, { list: "groceries" }, context);
|
||||
const watch = await groceries.items.watch(context);
|
||||
console.log(" watch.value:", done(watch.value), "done");
|
||||
const changed = Promise.withResolvers<readonly Item[]>();
|
||||
watch.start(async (call) => {
|
||||
if (call.kind === "value") changed.resolve(call.value);
|
||||
});
|
||||
await groceries.tick({ id: 2, done: true }, context);
|
||||
console.log(" the listener's first call:", done(await changed.promise), "done");
|
||||
await watch.stop();
|
||||
|
||||
// 5. The terminal client (Node 26.5.1 or later): the launcher signs in with the owner token file, downloads the
|
||||
// terminal's base bundle and the plugins' terminal scripts and runs them in its sandbox, here on a scripted terminal.
|
||||
const frames: string[] = [];
|
||||
let terminal: RunningTerminal | undefined;
|
||||
if (sandboxAvailable()) {
|
||||
const output = Object.assign(new PassThrough(), { columns: 60, rows: 12 });
|
||||
output.on("data", (chunk: Buffer) => frames.push(chunk.toString()));
|
||||
terminal = await startTerminal({
|
||||
url,
|
||||
credentials: { kind: "file", path: app.ownerTokenFile },
|
||||
input: new PassThrough(),
|
||||
output,
|
||||
colors: "truecolor",
|
||||
});
|
||||
await until(() => screen(frames).includes("butter"));
|
||||
console.log("5. the terminal shows:");
|
||||
for (const line of screen(frames).trim().split("\r\n")) console.log(` ${line}`);
|
||||
} else console.log("5. (the terminal client needs Node 26.5.1 or later for its sandbox)");
|
||||
|
||||
// 6. A theme plugin added while the app runs: a new directory in the plugins root marks the set changed, and applying
|
||||
// the changes builds it and rolls it out. Pages and terminals restyle in place.
|
||||
await cp(fileURLToPath(new URL("./later/harbor/", import.meta.url)), join(root, "harbor"), { recursive: true });
|
||||
await until(() => app.plugins.state.value.set);
|
||||
const added = await app.applyChanges(context);
|
||||
console.log("6. harbor added, rolled out to:", added.steps.map(({ name }) => name).join(", "));
|
||||
const scripts = await app.host.get(WebScripts, {}, context);
|
||||
console.log(" the page's plugins:", scripts.set.value.plugins.map(({ name }) => name).join(", "));
|
||||
scripts.release();
|
||||
if (terminal !== undefined) {
|
||||
await until(() => frames.join("").includes("\x1b[38;2;242;166;90mGroceries"));
|
||||
console.log(
|
||||
" the terminal redraws its title in Harbor's accent:",
|
||||
JSON.stringify("\x1b[38;2;242;166;90mGroceries"),
|
||||
);
|
||||
}
|
||||
|
||||
// 7. A second user. Ada picks a name (the kit's ephemeral login) and connects a client host of her own over the links
|
||||
// endpoint, as a page does. A host serves nothing before `load`: a client with nothing to load calls `load([])`.
|
||||
const login = await fetch(`${url}/_chord/login`, {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({ name: "ada" }),
|
||||
});
|
||||
const adaCookie = login.headers.get("set-cookie")!.split(";")[0]!;
|
||||
const ada = createHost<TodoPrincipal>({ onError: (error) => console.error("ada:", error.kind) });
|
||||
try {
|
||||
ada.hold(Todos, { list: "groceries" }, context);
|
||||
} catch (error) {
|
||||
console.log("7. ada's hold before load:", (error as Error).message);
|
||||
}
|
||||
await ada.load([], context);
|
||||
const socket = new WebSocket(`${url.replace(/^http/, "ws")}/chord`, { headers: { cookie: adaCookie } });
|
||||
ada.connect(webSocketLink(socket as unknown as WebSocketLike), { principal: { kind: "server" } }, context);
|
||||
const adas = await ada.get(Todos, { list: "groceries" }, context);
|
||||
await adas.add("coffee", context);
|
||||
console.log(" ada's variant:", adas.variant);
|
||||
if (adas.variant === "member") {
|
||||
// @ts-expect-error `clear` is the owner's: TypeScript refuses it, and so does the worker.
|
||||
const clearing: Promise<null> = adas.clear(context);
|
||||
await clearing.catch((error: ChordError) => console.log(" ada's clear:", `${error.code}/${error.reason}`));
|
||||
}
|
||||
await until(() => groceries.items.value.some(({ by }) => by === "ada"));
|
||||
console.log(" the server's reference sees:", show(groceries.items.value.slice(-1)));
|
||||
|
||||
// 8. An edit applied in place: the lists plugin's worker facet trims a todo's text and ignores an empty one. The
|
||||
// watcher marks the plugin dirty; applying the changes rolls it out to the server, the list's worker, the page and
|
||||
// the terminal. On the worker each object of the plugin takes its fate: the store opens a native, so it restarts
|
||||
// (its todos are in the store, and it reads them again); the views wait for it, then go on with the new code. Ada's
|
||||
// reference follows them.
|
||||
// The edit: `add` in the `Todos` view, before and after.
|
||||
const BEFORE = "add: (text: string, ctx: Ctx) => store.add({ text, by }, ctx),";
|
||||
const AFTER = `add: async (text: string, ctx: Ctx) => {
|
||||
if (text.trim() !== "") await store.add({ text: text.trim(), by }, ctx);
|
||||
return null;
|
||||
},`;
|
||||
const workerFile = join(root, "lists", "worker.ts");
|
||||
await writeFile(workerFile, (await readFile(workerFile, "utf8")).replace(BEFORE, AFTER));
|
||||
await until(() => app.plugins.state.value.dirty.includes("@todo/lists"));
|
||||
const applied = await app.applyChanges(context);
|
||||
console.log("8. lists changed, rolled out to:", applied.steps.map(({ name }) => name).join(", "));
|
||||
for (const host of applied.steps.find(({ name }) => name === "list")!.hosts) {
|
||||
const fates = "report" in host ? host.report.objects.map(({ object, kind }) => `${object} ${kind}`) : [];
|
||||
console.log(` on ${host.host}:`, [...new Set(fates)].join(", "));
|
||||
}
|
||||
await adas.add(" tea ", context);
|
||||
await until(() => groceries.items.value.at(-1)?.text === "tea");
|
||||
console.log(" ada adds ' tea ':", show(groceries.items.value.slice(-1)));
|
||||
const count = groceries.items.value.length;
|
||||
await adas.add(" ", context);
|
||||
console.log(" ada adds ' ':", groceries.items.value.length === count ? "ignored" : "added");
|
||||
|
||||
// 9. Export and import. The list's running worker writes its own store and files as one archive and goes on; importing
|
||||
// the archive makes a new list with the same todos. An address that has data is never overwritten.
|
||||
const archive = join(base, "groceries.tar");
|
||||
await app.exportData(List({ id: "groceries" }), archive, context);
|
||||
console.log("9. exported groceries:", (await readFile(archive)).length > 0 ? "an archive" : "nothing");
|
||||
await app.importData(archive, List({ id: "picnic" }), context);
|
||||
const picnic = await app.host.get(Todos, { list: "picnic" }, context);
|
||||
console.log(" imported as picnic:", show(picnic.items.value));
|
||||
picnic.release();
|
||||
await app.importData(archive, List({ id: "groceries" }), context).catch((error: Error) => {
|
||||
console.log(" importing over groceries:", error.message.replace(base, "…"));
|
||||
});
|
||||
|
||||
if (process.argv.includes("--serve")) {
|
||||
console.log(`Open ${app.ownerLink()} (Ctrl-C to stop); terminal: 57-tui's terminal.ts with ${app.ownerTokenFile}`);
|
||||
} else {
|
||||
await terminal?.stop();
|
||||
groceries.release();
|
||||
adas.release();
|
||||
await ada.close(context);
|
||||
await app.close(context);
|
||||
await rm(base, { recursive: true, force: true });
|
||||
console.log("10. closed");
|
||||
}
|
||||
|
||||
/** Todos as one line: `[x] milk (owner), [ ] eggs (owner)`. */
|
||||
function show(items: readonly Item[]): string {
|
||||
return items.map((item) => `${item.done ? "[x]" : "[ ]"} ${item.text} (${item.by})`).join(", ");
|
||||
}
|
||||
|
||||
/** How many of `items` are done. */
|
||||
function done(items: readonly Item[]): number {
|
||||
return items.filter((item) => item.done).length;
|
||||
}
|
||||
|
||||
/** Waits until `check` holds. */
|
||||
async function until(check: () => boolean): Promise<void> {
|
||||
while (!check()) await new Promise((resolve) => setTimeout(resolve, 10));
|
||||
}
|
||||
|
||||
/** An asset's URL with its content hash masked, so the output is the same at every run. */
|
||||
function hashless(asset: string): string {
|
||||
return asset.replace(/\.[0-9a-f]{8,}\./, ".<hash>.");
|
||||
}
|
||||
|
||||
/** The terminal's last frame as text (its colours stripped). */
|
||||
function screen(written: readonly string[]): string {
|
||||
return written
|
||||
.join("")
|
||||
.split("\x1b[2J\x1b[H")
|
||||
.at(-1)!
|
||||
.replace(/\x1b\[[0-9;]*m/g, "");
|
||||
}
|
||||
|
||||
/** Whether this Node can run the terminal's sandbox. */
|
||||
function sandboxAvailable(): boolean {
|
||||
try {
|
||||
requireSandboxNode();
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
/* The lists panel: the active theme's colours (the kit writes them on :root), class names prefixed with the plugin's. */
|
||||
.lists-panel ul { list-style: none; padding: 0; margin: 0 0 0.75rem; }
|
||||
.lists-panel li { padding: 0.35rem 0; border-bottom: 1px solid var(--muted); cursor: pointer; }
|
||||
.lists-done { color: var(--muted); text-decoration: line-through; }
|
||||
.lists-by { float: right; color: var(--muted); font-size: 0.8em; }
|
||||
.lists-panel input { width: 100%; box-sizing: border-box; padding: 0.4rem; background: var(--bg); color: var(--fg);
|
||||
border: 1px solid var(--accent); border-radius: var(--radius, 0); }
|
||||
.lists-panel button { margin-top: 0.5rem; background: none; color: var(--accent); border: 1px solid var(--accent);
|
||||
border-radius: var(--radius, 0); padding: 0.3rem 0.6rem; }
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
{
|
||||
"name": "@todo/lists",
|
||||
"private": true,
|
||||
"type": "module"
|
||||
}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
// The lists plugin on the server: a JSON API for command-line clients. A route runs as whoever sent the request (the
|
||||
// kit answered 401 before this code runs when nobody signed in), so its `ctx.get` reaches the list's worker as them.
|
||||
|
||||
import { defineFacet, Todos } from "@todo/app/v1";
|
||||
import { Http } from "@todo/app/v1/server";
|
||||
|
||||
export default defineFacet((_host, self) => {
|
||||
self.contribute(Http, {
|
||||
routes: {
|
||||
"GET /api/lists/:list": async (request, ctx) => {
|
||||
const todos = await ctx.get(Todos, { list: request.params.list! });
|
||||
return { kind: "json", value: todos.items.value };
|
||||
},
|
||||
"POST /api/lists/:list": async (request, ctx) => {
|
||||
const todos = await ctx.get(Todos, { list: request.params.list! });
|
||||
await todos.add(String(await request.json()), ctx);
|
||||
return { kind: "json", value: "added" };
|
||||
},
|
||||
},
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,17 @@
|
|||
// The lists plugin in the terminal: the groceries list's todos, live; done ones muted.
|
||||
|
||||
import { defineFacet, Todos } from "@todo/app/v1";
|
||||
import { Screens } from "@todo/app/v1/tui";
|
||||
|
||||
export default defineFacet((_host, self) =>
|
||||
self.contribute(Screens, {
|
||||
title: "Groceries",
|
||||
open: (scope, theme) => {
|
||||
const items = scope.watch(scope.hold(Todos, { list: "groceries" }).items);
|
||||
return () =>
|
||||
(items() ?? []).map((item) =>
|
||||
item.done ? theme.fg("muted", `[x] ${item.text}`) : `[ ] ${item.text} ${theme.fg("muted", item.by)}`,
|
||||
);
|
||||
},
|
||||
}),
|
||||
);
|
||||
|
|
@ -0,0 +1,53 @@
|
|||
// The lists plugin on the page: a panel with a list's todos (live), a box to add one, a click to tick one off, and for
|
||||
// the owner "Clear done". The list is the page's `#hash`, `groceries` without one.
|
||||
|
||||
import { BACKGROUND_CONTEXT as context, defineFacet, Todos } from "@todo/app/v1";
|
||||
import { Panels, useHold, useWatch } from "@todo/app/v1/web";
|
||||
import { createElement as h, useState } from "react";
|
||||
import "./lists.css";
|
||||
|
||||
function TodoList() {
|
||||
const todos = useHold(Todos, { list: location.hash.slice(1) || "groceries" });
|
||||
const items = useWatch(todos?.items) ?? [];
|
||||
const [text, setText] = useState("");
|
||||
const add = (event: { preventDefault(): void }) => {
|
||||
event.preventDefault();
|
||||
if (todos === undefined || text.trim() === "") return;
|
||||
void todos.add(text, context);
|
||||
setText("");
|
||||
};
|
||||
return h(
|
||||
"div",
|
||||
{ className: "lists-panel" },
|
||||
h(
|
||||
"ul",
|
||||
null,
|
||||
...items.map((item) =>
|
||||
h(
|
||||
"li",
|
||||
{
|
||||
key: item.id,
|
||||
className: item.done ? "lists-done" : undefined,
|
||||
onClick: () => void todos?.tick({ id: item.id, done: !item.done }, context),
|
||||
},
|
||||
item.text,
|
||||
h("span", { className: "lists-by" }, item.by),
|
||||
),
|
||||
),
|
||||
),
|
||||
h(
|
||||
"form",
|
||||
{ onSubmit: add },
|
||||
h("input", {
|
||||
value: text,
|
||||
placeholder: "Add a todo",
|
||||
onChange: (event: { target: { value: string } }) => setText(event.target.value),
|
||||
}),
|
||||
),
|
||||
todos?.variant === "owner"
|
||||
? h("button", { type: "button", onClick: () => void todos.clear(context) }, "Clear done")
|
||||
: null,
|
||||
);
|
||||
}
|
||||
|
||||
export default defineFacet((_host, self) => self.contribute(Panels, { title: "Groceries", Component: TodoList }));
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
// The lists plugin's worker facet, tested alone with `testPlugin`: built as production builds it, evaluated with the
|
||||
// app's real API module and loaded on a host of its own (an in-memory store) at a list's address. Each test runs code
|
||||
// as a principal of its choice: a user gets the `member` variant, the owner `owner`; a todo keeps who added it.
|
||||
// Run from packages/chord:
|
||||
// node ../../node_modules/vitest/dist/cli.js --run test/examples/61-tutorial/plugins/lists/worker.test.ts
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { BACKGROUND_CONTEXT as context } from "../../../../../src/context/index.ts";
|
||||
import { type TestedPlugin, testPlugin } from "../../../../../src/node/plugins/index.ts";
|
||||
import * as v1 from "../../src/api/v1/index.ts";
|
||||
import type { TodoPrincipal } from "../../src/principals.ts";
|
||||
|
||||
const ADA = { kind: "user", id: "u1", name: "ada" } as const;
|
||||
const OWNER = { kind: "owner" } as const;
|
||||
|
||||
describe("the lists plugin on a list's worker", () => {
|
||||
let t: TestedPlugin<TodoPrincipal>;
|
||||
beforeEach(async () => {
|
||||
t = await testPlugin<TodoPrincipal>(
|
||||
fileURLToPath(new URL("./", import.meta.url)),
|
||||
{
|
||||
kind: "worker",
|
||||
api: { name: "@todo/app", versions: ["v1"] },
|
||||
modules: { "@todo/app/v1": v1 },
|
||||
address: v1.List({ id: "groceries" }),
|
||||
principal: { kind: "worker", list: "list|groceries" },
|
||||
},
|
||||
context,
|
||||
);
|
||||
});
|
||||
afterEach(() => t.close(context));
|
||||
|
||||
it("adds a todo as the principal that called", async () => {
|
||||
await t.root(
|
||||
async (ctx) => {
|
||||
const todos = await ctx.get(v1.Todos, { list: "groceries" });
|
||||
expect(todos.variant).toBe("member");
|
||||
await todos.add("coffee", ctx);
|
||||
expect(todos.items.value.map(({ text, by }) => `${text} (${by})`)).toEqual(["coffee (ada)"]);
|
||||
},
|
||||
context,
|
||||
{ principal: ADA },
|
||||
);
|
||||
});
|
||||
|
||||
it("lets the owner clear the done todos", async () => {
|
||||
await t.root(
|
||||
async (ctx) => {
|
||||
const todos = await ctx.get(v1.Todos, { list: "groceries" });
|
||||
if (todos.variant !== "owner") throw new Error(`the owner got ${todos.variant}`);
|
||||
await todos.add("milk", ctx);
|
||||
await todos.add("eggs", ctx);
|
||||
await todos.tick({ id: 1, done: true }, ctx);
|
||||
await todos.clear(ctx);
|
||||
expect(todos.items.value.map(({ text }) => text)).toEqual(["eggs"]);
|
||||
},
|
||||
context,
|
||||
{ principal: OWNER },
|
||||
);
|
||||
expect(t.unserved()).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
// The lists plugin on a list's worker. The todos live in a table of the worker's store (every one kept), written and
|
||||
// read by one local object, `Store`. A table has no watch, so the store keeps a live window itself: the newest 50
|
||||
// todos, read when it goes live and changed after each write (it is the only writer). Each principal's `Todos` view
|
||||
// adds todos as that principal, and gives the owner `clear`.
|
||||
|
||||
import {
|
||||
arr,
|
||||
bool,
|
||||
type Ctx,
|
||||
defineFacet,
|
||||
defineTable,
|
||||
eq,
|
||||
Item,
|
||||
int,
|
||||
local,
|
||||
method,
|
||||
num,
|
||||
obj,
|
||||
query,
|
||||
str,
|
||||
Todos,
|
||||
} from "@todo/app/v1";
|
||||
|
||||
/** Every todo of the list, by its number. */
|
||||
const Items = defineTable("todo.items", {
|
||||
version: 1,
|
||||
columns: { id: int(), text: str(), done: bool(), by: str(), at: num() },
|
||||
key: ["id"],
|
||||
});
|
||||
|
||||
/** The newest todos first. */
|
||||
const NEWEST = query(Items).orderBy((item) => item.id, "desc");
|
||||
|
||||
/** How many todos the live window holds. */
|
||||
const WINDOW = 50;
|
||||
|
||||
/** The list's todos, once per worker: empty params, so it lives as long as the worker and may keep data in its store. */
|
||||
const Store = local("todo.store", {
|
||||
version: 1,
|
||||
params: {},
|
||||
members: {
|
||||
items: arr(Item),
|
||||
add: method(obj({ text: str(), by: str() })),
|
||||
tick: method(obj({ id: int(), done: bool() })),
|
||||
clear: method(),
|
||||
},
|
||||
});
|
||||
|
||||
export default defineFacet((host) => {
|
||||
host.serve(Store, (_params, self) => {
|
||||
const items = self.table(Items);
|
||||
// The newest todos, oldest first, and the next todo's number: read once when the store goes live, then kept here.
|
||||
const window = self.writable<Item[]>([]);
|
||||
let next = 1;
|
||||
self.open(
|
||||
async (ctx) => {
|
||||
const { items: newest } = await items.page(NEWEST, WINDOW, ctx);
|
||||
window.set([...newest].reverse());
|
||||
next = (newest[0]?.id ?? 0) + 1;
|
||||
},
|
||||
() => {},
|
||||
);
|
||||
return {
|
||||
items: self.derive([window], ([value]) => value),
|
||||
add: async ({ text, by }, ctx) => {
|
||||
const item = { id: next++, text, done: false, by, at: Date.now() };
|
||||
await self.tx(async (tx) => tx.put(items, item), ctx);
|
||||
// In number order: two adds may commit in either order.
|
||||
window.set([...window.value, item].sort((a, b) => a.id - b.id).slice(-WINDOW));
|
||||
return null;
|
||||
},
|
||||
tick: async ({ id, done }, ctx) => {
|
||||
await self.tx(async (tx) => tx.updateWhere(items, (item) => eq(item.id, id), { done }), ctx);
|
||||
window.set(window.value.map((item) => (item.id === id ? { ...item, done } : item)));
|
||||
return null;
|
||||
},
|
||||
clear: async (ctx) => {
|
||||
await self.tx(async (tx) => tx.deleteWhere(items, (item) => eq(item.done, true)), ctx);
|
||||
window.set(window.value.filter((item) => !item.done));
|
||||
return null;
|
||||
},
|
||||
};
|
||||
});
|
||||
host.serve(Todos, (_params, principal, self) => {
|
||||
const store = self.get(Store, {});
|
||||
// Who adds a todo comes from the principal the link stamped, never from the arguments.
|
||||
const by = principal.kind === "user" ? principal.name : principal.kind;
|
||||
const members = {
|
||||
items: self.derive([store.items], ([items]) => items),
|
||||
add: (text: string, ctx: Ctx) => store.add({ text, by }, ctx),
|
||||
tick: (tick: { id: number; done: boolean }, ctx: Ctx) => store.tick(tick, ctx),
|
||||
};
|
||||
if (principal.kind !== "owner") return { variant: "member", members };
|
||||
return { variant: "owner", members: { ...members, clear: (ctx: Ctx) => store.clear(ctx) } };
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
// The kit's pick-a-name login, added to the app as a single-file plugin: a server facet, one line.
|
||||
|
||||
import { login } from "@todo/app/v1/server";
|
||||
|
||||
export default login;
|
||||
95
packages/chord/test/examples/61-tutorial/screenshots.ts
Normal file
95
packages/chord/test/examples/61-tutorial/screenshots.ts
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
// Screenshots of the tutorial's page (`docs/tutorial/screens/`), taken in headless Chromium: the page after the owner
|
||||
// signed in with the owner link, the page in the Harbor theme added while it runs, and the page after an edit of the
|
||||
// lists plugin's web facet applied in place.
|
||||
// Run from packages/chord, with Chromium (`CHROMIUM_BIN`, else `chromium-docker` on the `PATH`):
|
||||
// node --conditions=source --experimental-strip-types test/examples/61-tutorial/screenshots.ts docs/tutorial/screens
|
||||
import { cp, mkdir, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { join, resolve } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { BACKGROUND_CONTEXT as context } from "../../../src/context/index.ts";
|
||||
import { Browser, CHROMIUM } from "../../node/web/browser.ts";
|
||||
import { Todos } from "./src/api/v1/index.ts";
|
||||
import { startTodo, todoDirectories } from "./start.ts";
|
||||
|
||||
const out = resolve(process.argv[2] ?? "screens");
|
||||
if (CHROMIUM === undefined) throw new Error("no Chromium: set CHROMIUM_BIN");
|
||||
await mkdir(out, { recursive: true });
|
||||
const { base, dataDir, root } = await todoDirectories();
|
||||
const app = await startTodo(dataDir, root, context);
|
||||
|
||||
// The list as the tutorial leaves it after chapter 7: the owner's todos, one by the server, one by Ada.
|
||||
const as = async (principal: Parameters<typeof app.host.root>[2], texts: readonly string[]): Promise<void> => {
|
||||
await app.host.root(
|
||||
async (ctx) => {
|
||||
const todos = await ctx.get(Todos, { list: "groceries" });
|
||||
for (const text of texts) await todos.add(text, ctx);
|
||||
},
|
||||
context,
|
||||
principal,
|
||||
);
|
||||
};
|
||||
await as({ principal: { kind: "owner" } }, ["milk", "eggs"]);
|
||||
await as(undefined, ["bread"]);
|
||||
await as({ principal: { kind: "owner" } }, ["butter"]);
|
||||
await as({ principal: { kind: "user", id: "u1", name: "ada" } }, ["coffee"]);
|
||||
await app.host.root(
|
||||
async (ctx) => (await ctx.get(Todos, { list: "groceries" })).tick({ id: 1, done: true }, ctx),
|
||||
context,
|
||||
{ principal: { kind: "owner" } },
|
||||
);
|
||||
|
||||
/** The edit of step 3: a line counting the open todos above the list. */
|
||||
const BEFORE = `h(
|
||||
"ul",`;
|
||||
const AFTER = `h("p", { className: "lists-open" }, \`\${items.filter((item) => !item.done).length} open\`),
|
||||
h(
|
||||
"ul",`;
|
||||
|
||||
const browser = await Browser.start();
|
||||
const shoot = async (name: string): Promise<void> => {
|
||||
await new Promise((done) => setTimeout(done, 300));
|
||||
const { data } = (await browser.call("Page.captureScreenshot", { format: "png" })) as { readonly data: string };
|
||||
await writeFile(join(out, name), Buffer.from(data, "base64"));
|
||||
console.log(`wrote ${join(out, name)}`);
|
||||
};
|
||||
try {
|
||||
await browser.call("Emulation.setDeviceMetricsOverride", {
|
||||
width: 560,
|
||||
height: 430,
|
||||
deviceScaleFactor: 1,
|
||||
mobile: false,
|
||||
});
|
||||
|
||||
// 1. The owner opens the owner link: the kit's page signs them in and goes to the app's page.
|
||||
await browser.open(app.ownerLink());
|
||||
await browser.wait("document.querySelectorAll('.lists-panel li').length === 5");
|
||||
await shoot("page.png");
|
||||
|
||||
// 2. The Harbor theme plugin added and applied; the browser prefers dark, so the page takes Harbor.
|
||||
await cp(fileURLToPath(new URL("./later/harbor/", import.meta.url)), join(root, "harbor"), { recursive: true });
|
||||
while (!app.plugins.state.value.set) await new Promise((done) => setTimeout(done, 10));
|
||||
await app.applyChanges(context);
|
||||
await browser.call("Emulation.setEmulatedMedia", { features: [{ name: "prefers-color-scheme", value: "dark" }] });
|
||||
await browser.wait("getComputedStyle(document.documentElement).getPropertyValue('--bg').trim() === '#12232e'");
|
||||
await shoot("harbor.png");
|
||||
|
||||
// 3. Half a todo typed, then an edit of the lists plugin's web facet applied: a count of the open todos.
|
||||
await browser.evaluate("document.querySelector('.lists-panel input').focus()");
|
||||
await browser.type("oat mi");
|
||||
// A mark on the page's window: a page that reloaded would have lost it.
|
||||
await browser.evaluate("window.marked = true");
|
||||
const typed = await browser.evaluate<string>("document.querySelector('.lists-panel input').value");
|
||||
const file = join(root, "lists", "web.ts");
|
||||
await writeFile(file, (await readFile(file, "utf8")).replace(BEFORE, AFTER));
|
||||
while (!app.plugins.state.value.dirty.includes("@todo/lists")) await new Promise((done) => setTimeout(done, 10));
|
||||
await app.applyChanges(context);
|
||||
await browser.wait("document.querySelector('.lists-open') !== null");
|
||||
await shoot("edited.png");
|
||||
const after = await browser.evaluate<string>("document.querySelector('.lists-panel input').value");
|
||||
console.log(`typed ${JSON.stringify(typed)}; after the edit: ${JSON.stringify(after)}`);
|
||||
console.log("the page did not reload:", await browser.evaluate<boolean>("window.marked === true"));
|
||||
} finally {
|
||||
await browser.close();
|
||||
await app.close(context);
|
||||
await rm(base, { recursive: true, force: true });
|
||||
}
|
||||
74
packages/chord/test/examples/61-tutorial/src/api/v1/index.ts
Normal file
74
packages/chord/test/examples/61-tutorial/src/api/v1/index.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
// The todo app's API, version 1, for every host kind (`@todo/app/v1`): the part of Chord its plugins use, `defineFacet`
|
||||
// typed with the app's principals, the app's host kind (a list, one worker each) and its contract for a list's todos.
|
||||
// Plugins import this module, never Chord: their builds leave it out and each process passes its own instance in.
|
||||
|
||||
import {
|
||||
arr,
|
||||
bool,
|
||||
defineFacet as chordFacet,
|
||||
type FacetHost,
|
||||
type FacetSelf,
|
||||
hostKind,
|
||||
int,
|
||||
method,
|
||||
num,
|
||||
obj,
|
||||
remote,
|
||||
type Static,
|
||||
str,
|
||||
} from "../../../../../../src/index.ts";
|
||||
import type { TodoPrincipal } from "../../principals.ts";
|
||||
|
||||
export { BACKGROUND_CONTEXT } from "../../../../../../src/context/index.ts";
|
||||
export {
|
||||
arr,
|
||||
bool,
|
||||
type Ctx,
|
||||
defineTable,
|
||||
eq,
|
||||
int,
|
||||
local,
|
||||
method,
|
||||
num,
|
||||
obj,
|
||||
PluginError,
|
||||
query,
|
||||
str,
|
||||
type Theme,
|
||||
Themes,
|
||||
} from "../../../../../../src/index.ts";
|
||||
export type { TodoPrincipal } from "../../principals.ts";
|
||||
|
||||
/** Defines a facet of the todo app: its factories get the app's principals. */
|
||||
export function defineFacet(setup: (host: FacetHost<TodoPrincipal>, self: FacetSelf) => void) {
|
||||
return chordFacet<TodoPrincipal>(setup);
|
||||
}
|
||||
|
||||
/** A todo list: a worker process of its own, with its own store. */
|
||||
export const List = hostKind("list", { id: str() });
|
||||
|
||||
/** A todo: its number in the list, its text, whether it is done, who added it and when. */
|
||||
export const Item = obj({ id: int(), text: str(), done: bool(), by: str(), at: num() });
|
||||
|
||||
/** A todo (see `Item`). */
|
||||
export type Item = Static<typeof Item>;
|
||||
|
||||
/**
|
||||
* A list's todos as one principal sees them, on the list's worker: the newest 50 (live), adding one and ticking one
|
||||
* off (or on again); the owner also clears the done ones.
|
||||
*/
|
||||
export const Todos = remote("todo.todos", {
|
||||
version: 1,
|
||||
params: { list: str() },
|
||||
hostAddress: ({ list }) => List({ id: list }),
|
||||
variants: {
|
||||
member: {
|
||||
state: { items: arr(Item) },
|
||||
methods: { add: method(str()), tick: method(obj({ id: int(), done: bool() })) },
|
||||
},
|
||||
owner: {
|
||||
state: { items: arr(Item) },
|
||||
methods: { add: method(str()), tick: method(obj({ id: int(), done: bool() })), clear: method() },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
// The todo app's API v1 for server facets (`@todo/app/v1/server`): the HTTP registry plugins add routes to, and the
|
||||
// kit's pick-a-name login as a facet a one-line plugin exports.
|
||||
|
||||
import { ephemeralLoginFacet } from "../../../../../../src/node/app/index.ts";
|
||||
import type { TodoPrincipal } from "../../principals.ts";
|
||||
|
||||
export { Http } from "../../../../../../src/node/http/index.ts";
|
||||
|
||||
/** The kit's ephemeral login: `POST /_chord/login { name }` signs in as `{ kind: "user", id, name }`. */
|
||||
export const login = ephemeralLoginFacet<TodoPrincipal>();
|
||||
17
packages/chord/test/examples/61-tutorial/src/api/v1/tui.ts
Normal file
17
packages/chord/test/examples/61-tutorial/src/api/v1/tui.ts
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
// The todo app's API v1 for terminal facets (`@todo/app/v1/tui`): the `Screens` registry the terminal draws, one part
|
||||
// per contributing plugin.
|
||||
|
||||
import { defineRegistry } from "../../../../../../src/index.ts";
|
||||
import type { TerminalTheme, TuiScope } from "../../../../../../src/tui/index.ts";
|
||||
|
||||
/**
|
||||
* A part of the terminal's screen: its title, and `open`, which holds what the part shows in `scope` (released when the
|
||||
* terminal closes it) and returns a reader of its lines, coloured by role with `theme`.
|
||||
*/
|
||||
export type Screen = {
|
||||
readonly title: string;
|
||||
readonly open: (scope: TuiScope, theme: TerminalTheme) => () => readonly string[];
|
||||
};
|
||||
|
||||
/** The terminal's parts, in plugin-name order. */
|
||||
export const Screens = defineRegistry<Screen, readonly Screen[]>("todo.screens", { version: 1 });
|
||||
13
packages/chord/test/examples/61-tutorial/src/api/v1/web.ts
Normal file
13
packages/chord/test/examples/61-tutorial/src/api/v1/web.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
// The todo app's API v1 for web facets (`@todo/app/v1/web`): the `Panels` registry the page shows, and Chord's React
|
||||
// hooks (a plugin imports them from here: Chord itself is not a package plugins may name).
|
||||
|
||||
import type { ComponentType } from "react";
|
||||
import { defineRegistry } from "../../../../../../src/index.ts";
|
||||
|
||||
export { useHold, useWatch } from "../../../../../../src/react/index.ts";
|
||||
|
||||
/** A panel of the page: its title and its React component. */
|
||||
export type Panel = { readonly title: string; readonly Component: ComponentType };
|
||||
|
||||
/** The page's panels, one per contributing plugin, in plugin-name order. */
|
||||
export const Panels = defineRegistry<Panel, readonly Panel[]>("todo.panels", { version: 1 });
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
// The todo app's principals: its server and list workers, the kit's owner, and the users who signed in with a name.
|
||||
|
||||
/** Who someone is in the todo app. */
|
||||
export type TodoPrincipal =
|
||||
| { readonly kind: "server" }
|
||||
| { readonly kind: "worker"; readonly list: string }
|
||||
| { readonly kind: "owner" }
|
||||
| { readonly kind: "user"; readonly id: string; readonly name: string };
|
||||
36
packages/chord/test/examples/61-tutorial/src/tui/main.ts
Normal file
36
packages/chord/test/examples/61-tutorial/src/tui/main.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
// The todo app's terminal entry, the base bundle the launcher's sandbox runs: `startTerminalRuntime` with the app's
|
||||
// terminal modules, a core facet serving the `Screens` registry, and a redraw on every change. The screens are opened in
|
||||
// a scope of their own, opened again when a reload changes them; `q` quits.
|
||||
|
||||
import { defineFacet } from "../../../../../src/index.ts";
|
||||
import { startTerminalRuntime, TuiScope } from "../../../../../src/tui/index.ts";
|
||||
import * as v1 from "../api/v1/index.ts";
|
||||
import * as v1tui from "../api/v1/tui.ts";
|
||||
|
||||
/** The terminal's own facet: it serves `Screens`, composing the contributions in plugin-name order. */
|
||||
const core = defineFacet((host) => host.serve(v1tui.Screens, { compose: (table) => table.map(({ value }) => value) }));
|
||||
|
||||
void startTerminalRuntime({
|
||||
modules: { "@todo/app/v1": v1, "@todo/app/v1/tui": v1tui },
|
||||
core: [{ name: "@todo/tui", facet: core }],
|
||||
render: (runtime) => {
|
||||
const { terminal, theme } = runtime;
|
||||
const screens = new TuiScope(runtime.host, () => draw()).registry(v1tui.Screens);
|
||||
let opened: { screens: readonly v1tui.Screen[]; scope: TuiScope; lines: (() => readonly string[])[] } | undefined;
|
||||
const draw = (): void => {
|
||||
const current = screens() ?? [];
|
||||
if (opened?.screens !== current) {
|
||||
opened?.scope.dispose();
|
||||
const scope = new TuiScope(runtime.host, () => draw());
|
||||
opened = { screens: current, scope, lines: current.map((screen) => screen.open(scope, theme)) };
|
||||
}
|
||||
const lines = current.flatMap((screen, n) => [theme.fg("accent", screen.title), ...opened!.lines[n]!()]);
|
||||
terminal.write(`\x1b[2J\x1b[H${lines.join("\r\n")}\r\n`);
|
||||
};
|
||||
theme.onChange(draw);
|
||||
terminal.onInput((data) => {
|
||||
if (data === "q") runtime.exit(0);
|
||||
});
|
||||
draw();
|
||||
},
|
||||
});
|
||||
33
packages/chord/test/examples/61-tutorial/src/web/main.ts
Normal file
33
packages/chord/test/examples/61-tutorial/src/web/main.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
// The todo app's web entry, the base bundle the server builds at start: `startPage` with the app's web modules and
|
||||
// React, a core facet serving the `Panels` registry, and a page showing every panel the plugins contribute.
|
||||
|
||||
import * as React from "react";
|
||||
import { createElement as h } from "react";
|
||||
import * as jsx from "react/jsx-runtime";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { defineFacet } from "../../../../../src/index.ts";
|
||||
import { HostProvider, useRegistry } from "../../../../../src/react/index.ts";
|
||||
import { startPage } from "../../../../../src/web/index.ts";
|
||||
import * as v1 from "../api/v1/index.ts";
|
||||
import * as v1web from "../api/v1/web.ts";
|
||||
import "./style.css";
|
||||
|
||||
/** The page's own facet: it serves `Panels`, composing the contributions in plugin-name order. */
|
||||
const core = defineFacet((host) => host.serve(v1web.Panels, { compose: (table) => table.map(({ value }) => value) }));
|
||||
|
||||
function Page() {
|
||||
const panels = useRegistry(v1web.Panels) ?? [];
|
||||
return h(
|
||||
"main",
|
||||
null,
|
||||
h("h1", null, "Todo"),
|
||||
...panels.map(({ title, Component }) => h("section", { key: title }, h("h2", null, title), h(Component))),
|
||||
);
|
||||
}
|
||||
|
||||
void startPage({
|
||||
connect: () => new WebSocket(`${location.origin.replace(/^http/, "ws")}/chord`),
|
||||
modules: { "@todo/app/v1": v1, "@todo/app/v1/web": v1web, react: React, "react/jsx-runtime": jsx },
|
||||
core: [{ name: "@todo/web", facet: core }],
|
||||
render: (host) => createRoot(document.getElementById("root")!).render(h(HostProvider, { host }, h(Page))),
|
||||
});
|
||||
|
|
@ -0,0 +1,5 @@
|
|||
/* The page's look: the active theme's colours, which the kit writes on :root as CSS variables. */
|
||||
body { margin: 0; font: 15px system-ui, sans-serif; background: var(--bg); color: var(--fg); }
|
||||
main { max-width: 26rem; margin: 1.5rem auto; padding: 0 1rem; }
|
||||
h1 { color: var(--accent); font-size: 1.4rem; margin: 0 0 1rem; }
|
||||
h2 { font-size: 0.8rem; text-transform: uppercase; letter-spacing: 0.08em; color: var(--muted); }
|
||||
76
packages/chord/test/examples/61-tutorial/start.ts
Normal file
76
packages/chord/test/examples/61-tutorial/start.ts
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
// The todo app's start: what the app decides itself, given to the kit's `startServer`: its name and data directory,
|
||||
// its API, its facet kinds, its principals, its one worker kind (a list), its page and its terminal client. The kit
|
||||
// does the rest. The examples call `startTodo` with a copy of `plugins/` they may edit; a real app's start script calls
|
||||
// it with the data directory from its command line and prints the owner link.
|
||||
|
||||
import { cp, mkdtemp } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import type { Context } from "../../../src/context/index.ts";
|
||||
import { type App, startServer } from "../../../src/node/app/index.ts";
|
||||
import { type PageAssets, pageScripts } from "../../../src/node/surfaces/index.ts";
|
||||
import * as v1 from "./src/api/v1/index.ts";
|
||||
import * as v1server from "./src/api/v1/server.ts";
|
||||
import type { TodoPrincipal } from "./src/principals.ts";
|
||||
|
||||
/** The app's API: its name, its versions (newest first), and this process's instances of its modules. */
|
||||
export const API = {
|
||||
name: "@todo/app",
|
||||
versions: ["v1"],
|
||||
modules: { "@todo/app/v1": v1, "@todo/app/v1/server": v1server },
|
||||
};
|
||||
|
||||
/** The app's facet kinds: a plugin has one file per kind it runs on (`server.ts`, `worker.ts`, `web.ts`, `tui.ts`). */
|
||||
export const KINDS = {
|
||||
server: { platform: "node" },
|
||||
worker: { platform: "node" },
|
||||
web: { platform: "browser", packages: { react: {}, "react/jsx-runtime": {} } },
|
||||
tui: { platform: "node" },
|
||||
} as const;
|
||||
|
||||
/** The page: the app's own HTML around the assets the kit made (the base bundle, the plugins' scripts and styles). */
|
||||
function page(request: { readonly nonce: string }, assets: PageAssets): string {
|
||||
return `<!doctype html><html lang="en"><head><meta charset="utf-8"><title>Todo</title>${pageScripts(assets, request.nonce)}</head><body><div id="root"></div></body></html>`;
|
||||
}
|
||||
|
||||
/** Starts the todo app on a free loopback port, with its data in `dataDir` and its built-in plugins in `root`. */
|
||||
export function startTodo(dataDir: string, root: string, context: Context): Promise<App<TodoPrincipal>> {
|
||||
return startServer<TodoPrincipal>(
|
||||
{
|
||||
name: "todo",
|
||||
dataDir,
|
||||
root,
|
||||
listen: { host: "127.0.0.1", port: 0 },
|
||||
api: API,
|
||||
kinds: KINDS,
|
||||
principal: { server: { kind: "server" }, worker: (address) => ({ kind: "worker", list: address.key }) },
|
||||
workers: [
|
||||
{
|
||||
kind: v1.List,
|
||||
entry: fileURLToPath(new URL("./worker.ts", import.meta.url)),
|
||||
execArgv: ["--conditions=source", "--experimental-strip-types", "--no-warnings"],
|
||||
start: () => ({}),
|
||||
// Who may reach a list's worker: everyone signed in, and the server itself.
|
||||
authorize: (principal) => principal.kind !== "worker",
|
||||
},
|
||||
],
|
||||
web: { entry: fileURLToPath(new URL("./src/web/main.ts", import.meta.url)), page },
|
||||
tui: { entry: fileURLToPath(new URL("./src/tui/main.ts", import.meta.url)) },
|
||||
onError: (error) => console.error("todo:", error.kind, error.error),
|
||||
},
|
||||
context,
|
||||
);
|
||||
}
|
||||
|
||||
/** A data directory and a copy of the app's plugins, in a new temporary directory. */
|
||||
export async function todoDirectories(): Promise<{
|
||||
readonly base: string;
|
||||
readonly dataDir: string;
|
||||
readonly root: string;
|
||||
}> {
|
||||
const base = await mkdtemp(join(tmpdir(), "todo-"));
|
||||
const root = join(base, "plugins");
|
||||
await cp(fileURLToPath(new URL("./plugins/", import.meta.url)), root, { recursive: true });
|
||||
return { base, dataDir: join(base, "data"), root };
|
||||
}
|
||||
116
packages/chord/test/examples/61-tutorial/tutorial.test.ts
Normal file
116
packages/chord/test/examples/61-tutorial/tutorial.test.ts
Normal file
|
|
@ -0,0 +1,116 @@
|
|||
// The tutorial's checks (`docs/tutorial/book.md`): every TypeScript, JSON and CSS block of the book is quoted from this
|
||||
// example's files (`<!-- quote: path -->` above it) and fails here when the file no longer has those lines; the
|
||||
// example's steps run (`main.ts`, and `screenshots.ts` with Chromium), and every console output the book shows
|
||||
// (`<!-- output: main -->`, `<!-- output: screenshots -->`) is among the lines they print, in order. The terminal's
|
||||
// output (`<!-- output: tui -->`) is checked when a Node for its sandbox is there (`CHORD_TUI_NODE`, else `node26`).
|
||||
// The p2 chapter's blocks (`<!-- p2: path -->`) quote p2's sources, which live outside this repository.
|
||||
// Run from packages/chord:
|
||||
// node ../../node_modules/vitest/dist/cli.js --run test/examples/61-tutorial/tutorial.test.ts
|
||||
import { execFile } from "node:child_process";
|
||||
import { mkdtempSync, readFileSync, rmSync } from "node:fs";
|
||||
import { tmpdir } from "node:os";
|
||||
import { join } from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { promisify } from "node:util";
|
||||
import { describe, expect, it } from "vitest";
|
||||
import { TUI_NODE } from "../../node/tui/support.ts";
|
||||
import { CHROMIUM } from "../../node/web/browser.ts";
|
||||
|
||||
const HERE = fileURLToPath(new URL("./", import.meta.url));
|
||||
const BOOK = readFileSync(new URL("../../../docs/tutorial/book.md", import.meta.url), "utf8");
|
||||
|
||||
/** A fenced block of the book: its language, the marker comment right above it (if any), its text and line. */
|
||||
type Block = {
|
||||
readonly lang: string;
|
||||
readonly marker: string | undefined;
|
||||
readonly text: string;
|
||||
readonly line: number;
|
||||
};
|
||||
|
||||
const BLOCKS = blocksOf(BOOK);
|
||||
|
||||
/** Every fenced block of `book`, with the `<!-- … -->` marker on the line before its opening fence. */
|
||||
function blocksOf(book: string): Block[] {
|
||||
const lines = book.split("\n");
|
||||
const found: Block[] = [];
|
||||
for (let n = 0; n < lines.length; n++) {
|
||||
const open = /^```(\w*)$/.exec(lines[n]!);
|
||||
if (open === null) continue;
|
||||
const end = lines.indexOf("```", n + 1);
|
||||
const marker = /^<!-- (.+) -->$/.exec(lines[n - 1] ?? "")?.[1];
|
||||
found.push({ lang: open[1]!, marker, text: lines.slice(n + 1, end).join("\n"), line: n + 1 });
|
||||
n = end;
|
||||
}
|
||||
return found;
|
||||
}
|
||||
|
||||
/** The blocks whose marker is `<kind>: <value>`, with the value. */
|
||||
function marked(kind: string): { readonly block: Block; readonly value: string }[] {
|
||||
return BLOCKS.flatMap((block) => {
|
||||
const value = block.marker?.startsWith(`${kind}: `) ? block.marker.slice(kind.length + 2) : undefined;
|
||||
return value === undefined ? [] : [{ block, value }];
|
||||
});
|
||||
}
|
||||
|
||||
/** Fails unless every line of `block` is among `printed`'s lines, in order. */
|
||||
function expectPrinted(block: Block, printed: string): void {
|
||||
const lines = printed.split("\n").map((line) => line.trimEnd());
|
||||
let at = 0;
|
||||
for (const line of block.text.split("\n")) {
|
||||
const found = lines.indexOf(line.trimEnd(), at);
|
||||
expect(found, `book.md:${block.line}: not printed (in this order): ${line}`).toBeGreaterThanOrEqual(0);
|
||||
at = found + 1;
|
||||
}
|
||||
}
|
||||
|
||||
/** Runs one of the example's scripts with `node` from packages/chord: what it printed. */
|
||||
async function run(node: string, script: string, ...args: string[]): Promise<string> {
|
||||
const flags = ["--conditions=source", "--experimental-strip-types", "--no-warnings"];
|
||||
const { stdout } = await promisify(execFile)(node, [...flags, join(HERE, script), ...args], {
|
||||
cwd: join(HERE, "../../.."),
|
||||
timeout: 180_000,
|
||||
});
|
||||
return stdout;
|
||||
}
|
||||
|
||||
describe("the tutorial's code blocks", () => {
|
||||
it("quotes every TypeScript, JSON and CSS block from a file", () => {
|
||||
const unquoted = BLOCKS.filter(
|
||||
({ lang, marker }) =>
|
||||
["ts", "tsx", "json", "css"].includes(lang) &&
|
||||
!marker?.startsWith("quote: ") &&
|
||||
!marker?.startsWith("p2: "),
|
||||
);
|
||||
expect(unquoted.map(({ line }) => `book.md:${line}`)).toEqual([]);
|
||||
});
|
||||
|
||||
it("matches the example's files", () => {
|
||||
const quotes = marked("quote");
|
||||
expect(quotes.length).toBeGreaterThan(20);
|
||||
for (const { block, value } of quotes) {
|
||||
const file = readFileSync(join(HERE, value), "utf8");
|
||||
expect(file.includes(block.text), `book.md:${block.line}: ${value} no longer has this block`).toBe(true);
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
describe("the tutorial's steps", () => {
|
||||
it("prints what the book shows", { timeout: 200_000 }, async () => {
|
||||
const printed = await run(TUI_NODE ?? process.execPath, "main.ts");
|
||||
for (const { block } of marked("output").filter(({ value }) => value === "main")) expectPrinted(block, printed);
|
||||
if (TUI_NODE === undefined) return;
|
||||
for (const { block } of marked("output").filter(({ value }) => value === "tui")) expectPrinted(block, printed);
|
||||
});
|
||||
|
||||
it.skipIf(CHROMIUM === undefined)("takes the screenshots", { timeout: 200_000 }, async () => {
|
||||
const out = mkdtempSync(join(tmpdir(), "todo-screens-"));
|
||||
try {
|
||||
const printed = await run(process.execPath, "screenshots.ts", out);
|
||||
for (const { block } of marked("output").filter(({ value }) => value === "screenshots")) {
|
||||
expectPrinted(block, printed.replaceAll(out, "docs/tutorial/screens"));
|
||||
}
|
||||
} finally {
|
||||
rmSync(out, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
7
packages/chord/test/examples/61-tutorial/worker.ts
Normal file
7
packages/chord/test/examples/61-tutorial/worker.ts
Normal file
|
|
@ -0,0 +1,7 @@
|
|||
// The todo app's worker entry: one process per list. `startWorker` opens the list's store in its directory, connects
|
||||
// to the server and loads the plugins' worker facets.
|
||||
|
||||
import { startWorker } from "../../../src/node/app/index.ts";
|
||||
import { API, KINDS } from "./start.ts";
|
||||
|
||||
startWorker({ api: API, kinds: KINDS, onError: (error) => console.error("list:", error) });
|
||||
|
|
@ -31,6 +31,10 @@
|
|||
"@notes/app/*": ["./packages/chord/test/examples/53-app/app/*.ts"],
|
||||
"@board/app/*": ["./packages/chord/test/examples/56-web/app/*.ts"],
|
||||
"@chat/app/*": ["./packages/chord/test/examples/59-chat/app/*.ts"],
|
||||
"@todo/app/*": [
|
||||
"./packages/chord/test/examples/61-tutorial/src/api/*.ts",
|
||||
"./packages/chord/test/examples/61-tutorial/src/api/*/index.ts"
|
||||
],
|
||||
"@earendil-works/pi-telemetry": ["./packages/telemetry/src/index.ts"],
|
||||
"@earendil-works/pi-telemetry/testing": ["./packages/telemetry/src/testing/index.ts"],
|
||||
"@earendil-works/pi-telemetry/render": ["./packages/telemetry/src/render.ts"],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue