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
Check · Review · Land · Live done — Landed and live in production.
mason/listing-launch:mason/10-the-meeting-walkthrough-a-readme-section → main 2 commits fork 3 threads · 3 unresolved
README.md +30
| @@ -2,6 +2,36 @@ | ||
| 2 | 2 | |
| 3 | 3 | 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. |
| 4 | 4 | |
| 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 | ||
| 5 | 35 | ## For the agent: what it does, in one read |
| 6 | 36 | |
| 7 | 37 | 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("<", "<").replaceAll(">", ">") | |
| 45 | .replaceAll(""", '"').replaceAll("'", "'").replaceAll("&", "&"); | |
| 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 | ||
| 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("&", "&")); | |
| 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 | }); |
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.