Public: anyone can read this; the crew can change it.

The meeting walkthrough: a README section a realtor follows, and a test that walks it #14

Merged opened this

Check · Review · Land · Live done — Landed and live in production.

Merged

mason/listing-launch:mason/10-the-meeting-walkthrough-a-readme-section → main 2 commits fork 3 threads · 3 unresolved

  • Merged. mason/10-the-meeting-walkthrough-a-readme-section was merged into main as 997a930.

Review

Sign in to review this change.
2 files changed +183 −0
README.md +30
@@ -2,6 +2,36 @@
22
33 One address in, a listing launched: the MLS description, three social posts, a flyer and an email blast, each checked for Fair Housing language and dressed in the agent's own brand.
44
5 ## Try it in five minutes
6
7 Sign in first; you can read everything without, but you need to be signed in
8 to make a listing of your own.
9
10 1. **Open the sample.** On the first screen, under "Launched", press the
11 card for *412 Alder Lane, Portland*. Its page shows the price, the
12 photos and the words, in tabs: MLS, Social, Flyer and Email.
13 2. **Read an asset and its check.** The MLS tab has the description in a box,
14 and beside it the **Fair Housing** check: green when it is clear. Open the
15 card for *27 Birchwood Court, Boise* to see it catch something: the
16 risky words are underlined, each with a one-line reason.
17 3. **Start a new one from the sample.** Back on Alder Lane, press **Start a
18 new one from this**. The form fills in with the sample's facts. Change
19 the address to your own listing, then press **Launch**. The sample
20 is only to read; the new listing is yours to change.
21 4. **Approve one.** On your listing, read the MLS description, then press
22 **Approve** under it (**Approve with 1 flag** when the check still has
23 something to say). The top of the page now says "1 of 6 approved".
24 5. **Change the tone.** Pick another tone beside the address (Classic, Warm
25 or Bold) and press **Rewrite all**. Every asset is written again in that
26 tone, your edits are replaced, and the approval is cleared: it says
27 "0 of 6 approved" again, because you have not read the new words yet.
28 6. **Print the flyer.** Press **Print flyer**: a one-page flyer opens with
29 the photo, price, facts, description and your name. Print it or save it
30 as a PDF.
31
32 `src/walkthrough.test.ts` walks these steps on every `bun test`, so this
33 list cannot drift from the app.
34
535 ## For the agent: what it does, in one read
636
737 1. **Type the property in.** On the first screen: the address, beds, baths,
src/walkthrough.test.ts added +153
@@ -0,0 +1,153 @@
1 /**
2 * The README's "Try it in five minutes", walked over HTTP as a signed-in
3 * person on a fresh data directory. Every label the section names is
4 * asserted on the page that shows it, and each step must answer as the
5 * section says it does.
6 */
7
8 import { afterAll, expect, test } from "bun:test";
9 import { mkdtempSync, readFileSync } from "node:fs";
10 import { tmpdir } from "node:os";
11 import { join } from "node:path";
12 import { SAMPLE } from "./sample.ts";
13 import { serve } from "./server.ts";
14 import { sign } from "./who.ts";
15
16 const KEY = "a-key-only-the-forge-holds";
17 const server = serve({
18 port: 0,
19 dataDir: mkdtempSync(join(tmpdir(), "walk-")),
20 identityKey: KEY,
21 });
22 const base = `http://${server.hostname}:${server.port}`;
23 afterAll(() => server.stop(true));
24
25 const identity = `u1|realtor|user|${Date.now() + 600_000}`;
26 const headers = {
27 "x-forge-identity": identity,
28 "x-forge-signature": sign(identity, KEY),
29 };
30 const get = (path: string) => fetch(base + path, { headers, redirect: "manual" });
31 const post = (path: string, form: FormData) =>
32 fetch(base + path, { method: "POST", headers, body: form, redirect: "manual" });
33
34 const readme = readFileSync(new URL("../README.md", import.meta.url), "utf8");
35 // Line breaks in the prose are not part of a label.
36 const flat = readme.replace(/\s+/g, " ");
37 const section = flat.slice(
38 flat.indexOf("## Try it in five minutes"),
39 flat.indexOf("## For the agent"),
40 );
41
42 const textareaOf = (page: string): string =>
43 /<textarea[^>]*>([^]*?)<\/textarea>/.exec(page)![1]!
44 .replaceAll("&lt;", "<").replaceAll("&gt;", ">")
45 .replaceAll("&quot;", '"').replaceAll("&#39;", "'").replaceAll("&amp;", "&");
46
47 /** A button or link carrying exactly this label, not a word that merely contains it. */
48 const control = (label: string): RegExp =>
49 new RegExp(`<(?:button|a)[^>]*>${label}<`);
50
51 /** The id in a listing card's link whose text holds the address. */
52 const idOf = (home: string, address: string): number => {
53 const card = home.split('<li class="listing-card">').find((c) => c.includes(`<b>${address}`));
54 expect(card).toBeDefined();
55 return Number(/href="\/listings\/(\d+)"/.exec(card!)![1]);
56 };
57
outdated

Jargon list covers only the three words the issue named; also check the section for other developer words (API, JSON, HTTP, cookie, header) since the section is for a realtor.

58 test("the README opens with the section, in plain words", () => {
59 expect(readme.indexOf("## Try it in five minutes")).toBeGreaterThan(0);
60 expect(readme.indexOf("## Try it in five minutes")).toBeLessThan(
61 readme.replace(/\s+/g, " ").indexOf("## For the agent"),
62 );
63 expect(readme).toContain("## For whoever runs it");
64 for (const jargon of ["endpoint", "MCP", "SQLite", "API", "JSON", "HTTP", "cookie", "header"])
65 expect(section).not.toMatch(new RegExp(`\\b${jargon}`, "i"));
outdated

Vacuous: "Launch" is a substring of "Launched", so this passes with no Launch button. Assert the button, e.g. toMatch(/<button[^>]*>Launch</), and do the same for every bold label the README names (Sign in on a guest GET / too).

66 });
67
68 test("every step of the section works, with the labels it names", async () => {
69 // 1. Open the sample.
70 const home = await get("/");
71 expect(home.status).toBe(200);
72 const homeHtml = await home.text();
73 expect(homeHtml).toContain("Launched");
74 expect(homeHtml).toMatch(control("Launch"));
75 // A guest is offered the way in the section names.
76 expect(section).toContain("Sign in");
77 const guest = await (await fetch(base + "/")).text();
78 expect(guest).toMatch(control("Sign in to launch"));
79 expect(guest).not.toMatch(control("Launch"));
80 expect(section).toContain("412 Alder Lane");
81 const sample = idOf(homeHtml, "412 Alder Lane");
82 const open = await get(`/listings/${sample}`);
83 expect(open.status).toBe(200);
84 const sampleHtml = await open.text();
85 for (const tab of ["MLS", "Social", "Flyer", "Email"]) {
86 expect(section).toContain(tab);
87 expect(sampleHtml).toMatch(new RegExp(`role="tab"[^>]*>${tab}[ <]`));
88 }
89
90 // 2. Read an asset and its check; the Boise sample is the one that catches something.
91 expect(sampleHtml).toContain("Fair Housing: clear");
92 expect(section).toContain("27 Birchwood Court");
93 const boise = await (await get(`/listings/${idOf(homeHtml, "27 Birchwood Court")}`)).text();
94 expect(boise).toMatch(/Fair Housing: \d+ to review/);
95
96 // 3. Start a new one from the sample, and launch.
97 for (const label of ["Start a new one from this", "Launch", "Print flyer", "Rewrite all"])
98 expect(section).toContain(`**${label}**`);
99 expect(sampleHtml).toMatch(control("Start a new one from this"));
100 const form = await get(`/?from=${sample}`);
101 expect(form.status).toBe(200);
102 expect(await form.text()).toContain(SAMPLE.address);
103 const launch = new FormData();
104 launch.set("address", "9 Walkthrough Way, Portland, OR 97214");
105 for (const k of ["beds", "baths", "sqft", "lot", "year", "price", "tone", "palette",
106 "agent_name", "brokerage", "license", "agent_phone", "agent_email"] as const)
107 launch.set(k, String(SAMPLE[k]));
108 launch.set("features", SAMPLE.features.join("\n"));
109 const launched = await post("/listings", launch);
110 expect(launched.status).toBe(303);
111 const mine = launched.headers.get("location")!;
112 expect(mine).toMatch(/^\/listings\/\d+$/);
113
114 // Make the words the realtor's own, so a rewrite has edits to replace.
115 const edit = new FormData();
116 edit.set("text", "My own words about 9 Walkthrough Way, written by hand.");
117 expect((await post(`${mine}/assets/mls`, edit)).status).toBe(303);
118
119 // 4. Approve one.
120 const page = await (await get(mine)).text();
outdated

Step 5 says every asset is written again in the new tone and edits are replaced, but this only checks "0 of 6 approved". Before the retone, Save an edited MLS text; after, assert the textarea no longer holds it and differs from the pre-tone text. Otherwise a tone route that only clears approvals passes.

121 expect(page).toContain("0 of 6 approved");
122 expect(section).toContain("1 of 6 approved");
123 expect(textareaOf(page)).toBe("My own words about 9 Walkthrough Way, written by hand.");
124 const approveForm = new FormData();
125 approveForm.set("approved", "1");
126 approveForm.set("seen", /name="seen" value="([^"]+)"/.exec(page)![1]!.replaceAll("&amp;", "&"));
127 approveForm.set("text", textareaOf(page));
128 expect(page).toMatch(control("Approve"));
129 const approved = await post(`${mine}/assets/mls/approve`, approveForm);
130 expect(approved.status).toBe(303);
131 const afterApprove = await (await get(mine)).text();
132 expect(afterApprove).toContain("1 of 6 approved");
133
134 // 5. Change the tone: every asset again, approval cleared.
135 expect(afterApprove).toMatch(control("Rewrite all"));
136 const tone = new FormData();
137 tone.set("tone", "bold");
138 expect((await post(`${mine}/tone`, tone)).status).toBe(303);
139 const afterTone = await (await get(mine)).text();
140 expect(afterTone).toContain("0 of 6 approved");
141 // The words were written again: the edit is gone and the text is the bold tone's.
142 expect(textareaOf(afterTone)).not.toContain("written by hand");
143 expect(textareaOf(afterTone)).not.toBe(textareaOf(afterApprove));
144 expect(afterTone).toContain('<option value="bold" selected>');
145 expect(textareaOf(afterApprove)).toContain("written by hand");
146 expect(section).toContain("0 of 6 approved");
147
148 // 6. Print the flyer.
149 expect(afterTone).toMatch(control("Print flyer"));
150 const flyer = await get(`${mine}/flyer`);
151 expect(flyer.status).toBe(200);
152 expect(await flyer.text()).toContain("9 Walkthrough Way");
153 });

↑↓ navigate ↵ open ⌘↵ its app esc close

Keyboard shortcuts

Docs