testUserinputExample
the userinput example: an AT Protocol client, against a fake network (no internet), in Chrome. It reads a space's board and a discussion as the moderation rules say, signs in, replies, votes and posts: records written to the person's own repo, pointing at what they're about.
import { CHROME, withPage } from "#codebase/tcmts/cli/test/browser"; import { env, exampleDir, startApp, startFakeAtproto } from "#codebase/tcmts/cli/test/e2e/examples"; import { tempDir, withChildren } from "#codebase/tcmts/cli/test/e2e/harness"; import { USERINPUT } from "#codebase/tcmts/fixtures"; import assert from "node:assert/strict"; import { spawnSync } from "node:child_process"; import path from "node:path"; import { stripVTControlCharacters } from "node:util"; export const testUserinputExample = async () => { if (!CHROME) return console.log("skipped: no Chrome"); const dir = path.join(tempDir("examples"), "userinput"); const setup = spawnSync(path.join(exampleDir(USERINPUT), "setup.sh"), [dir], { env: env(), encoding: "utf8", }); const out = stripVTControlCharacters(setup.stdout + setup.stderr); assert.equal(setup.status, 0, out); // (its own 14, and the react library's 3, mounted at userinput.lib.react) assert.match(out, /17 passed, 0 failed/); assert.doesNotMatch(out, /warning/); await withChildren(async (keep) => { const alice = { did: "did:plc:alice000000000000000000", handle: "alice.test", password: "a-pass", displayName: "Alice", }; const bob = { did: "did:plc:bob00000000000000000000", handle: "bob.test", password: "b-pass", displayName: "Bob", }; const net = await startFakeAtproto(keep, [alice, bob]); const at = new Date(Date.now() - 3_600_000).toISOString(); const space = net.seed(alice.did, "app.userinput.space", "s1", { name: "Alice's app", description: "Tell me what to build.", tags: [ { label: "Bug", value: "bug" }, { label: "Feature", value: "feature" }, ], createdAt: at, }); const ref = (r: { uri: string; cid: string }) => ({ uri: r.uri, cid: r.cid }); const idea = net.seed(bob.did, "app.userinput.discussion", "d1", { space: ref(space), title: "Offline mode", body: "Work **without** a connection.", tags: ["feature"], createdAt: at, }); net.seed(alice.did, "app.userinput.status", "st1", { subject: ref(idea), state: "planned", note: "Next month.", createdAt: at, }); // bob can't mark his own idea shipped: only the owner's and moderators' statuses count net.seed(bob.did, "app.userinput.status", "st2", { subject: ref(idea), state: "implemented", createdAt: new Date().toISOString(), }); net.seed(alice.did, "app.userinput.pin", "p1", { space: ref(space), subject: ref(idea), createdAt: at, }); net.seed(alice.did, "app.userinput.upvote", "d1", { subject: ref(idea), createdAt: at }); net.seed(alice.did, "app.userinput.reply", "r1", { subject: ref(idea), body: "Thanks!", createdAt: at, }); const url = await startApp(keep, dir, "userinput.app.App"); const services = JSON.stringify({ constellation: net.url, slingshot: net.url, appview: net.url, typeahead: net.url, plc: net.url, images: net.url, // (which has no index of boards: the app reads them record by record, as it does when userinput.app's can't say) index: net.url, }); await withPage(url, async (p) => { const text = (sel: string) => `[...document.querySelectorAll(${JSON.stringify(sel)})].map((e) => e.textContent).join("|")`; const press = (label: string) => p.evaluate( `[...document.querySelectorAll("button, a")].reverse().find((b) => (b.getAttribute("aria-label") ?? b.textContent.trim()) === ${JSON.stringify(label)})?.click()`, ); // (a wait that doesn't come is a failure, saying what the page showed) const until = async <T>( expr: string, ok: (v: T) => boolean, what: string, ms = 15000, ): Promise<T> => { const v = await p.waitFor<T>(expr, ok, ms); if (!ok(v)) assert.fail( `${what}; the page: ${(await p.evaluate<string>("document.body.innerText")).slice(0, 600)}`, ); return v; }; const fill = (sel: string, value: string) => p.evaluate( `(() => { const e = document.querySelector(${JSON.stringify(sel)}); const set = Object.getOwnPropertyDescriptor(Object.getPrototypeOf(e), "value").set; set.call(e, ${JSON.stringify(value)}); e.dispatchEvent(new Event("input", { bubbles: true })); })()`, ); await p.evaluate( `localStorage.setItem("userinput.services", ${JSON.stringify(services)}); location.href = "/s/${alice.did}/s1"`, ); // the board: the idea, pinned, as its owner set it (planned), with alice's vote and reply const board = await until<string>(text(".row"), (t) => /Offline mode/.test(t), "the board"); assert.match(board, /Offline mode[\s\S]*Planned[\s\S]*Feature/); assert.match(await p.evaluate<string>(text(".row .score")), /^1$/); assert.match( await p.evaluate<string>( `document.querySelector(".row-title [title=Pinned]") ? "pinned" : ""`, ), /pinned/, ); // the discussion: its body as Markdown, the status note, the reply await p.evaluate(`location.href = "/d/${bob.did}/d1"`); assert.match( await until<string>(text("main"), (t) => /Thanks!/.test(t), "the discussion"), /Next month\./, ); assert.match( await p.evaluate<string>(`document.querySelector(".body strong")?.textContent ?? ""`), /without/, ); // signing in, with a password await press("Sign in"); await press("Use an app password"); await fill('input[placeholder="you.bsky.social"]', bob.handle); await fill('input[type="password"]', bob.password); await press("Continue"); await until<string>( `localStorage.getItem("userinput.did") ?? ""`, (t) => t === `password:${bob.did}`, "signing in", ); // a reply and a vote: records in bob's repo, pointing at the discussion await until<string>( `document.querySelector('textarea') ? "ok" : ""`, (t) => t === "ok", "the reply box", ); await fill("textarea", "Count me in."); // (the discussion's own reply box, not a reply's) await p.evaluate(`document.querySelector(".composer button.primary").click()`); await until<number>( `0`, () => [...net.records.values()].some((r) => r.uri.startsWith(`at://${bob.did}/app.userinput.reply/`), ), "the reply written", 10000, ); const reply = [...net.records.values()].find((r) => r.uri.startsWith(`at://${bob.did}/app.userinput.reply/`), )!; assert.deepEqual( [reply.value.body, (reply.value.subject as { uri: string }).uri], ["Count me in.", idea.uri], ); await press("Upvote (score 1)"); await until<number>( `0`, () => net.records.has(`at://${bob.did}/app.userinput.upvote/d1`), "the vote written", 10000, ); // a new post, from the board await p.evaluate(`location.href = "/s/${alice.did}/s1"`); await until<string>(text(".row"), (t) => /Offline mode/.test(t), "the board again"); await press("New post"); await until<string>( `document.querySelector('input[placeholder="A short summary"]') ? "ok" : ""`, (t) => t === "ok", "the new post's sheet", ); await fill('input[placeholder="A short summary"]', "Keyboard shortcuts"); await fill("textarea", "j and k to move."); await press("Post"); await until<number>( `0`, () => [...net.records.values()].some((r) => r.value.title === "Keyboard shortcuts"), "the post written", 10000, ); await until<string>( `location.pathname`, (t) => t.startsWith(`/d/${bob.did}/`), "the new post's page", ); const posted = [...net.records.values()].find((r) => r.value.title === "Keyboard shortcuts")!; assert.deepEqual(posted.value.space, ref(space)); assert.deepEqual(p.errors, []); }); }); }
uses
- tcmts.cli.test.browser.CHROME
- tcmts.cli.test.browser.withPage
- tcmts.cli.test.e2e.examples.env
- tcmts.cli.test.e2e.examples.exampleDir
- tcmts.cli.test.e2e.examples.startApp
- tcmts.cli.test.e2e.examples.startFakeAtproto
- tcmts.cli.test.e2e.harness.tempDir
- tcmts.cli.test.e2e.harness.withChildren
- tcmts.fixtures.USERINPUT