testUserinputExample

tcmts.cli.test.e2e.examples.testUserinputExample · #jk5on2vu · term

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