import { expect } from "@playwright/test"; import { getOk } from "../harness/api"; import { config } from "../harness/config"; import { bobbin, type Ctx, flow, hydrant, link, not, pds, record, replacing, rkeyOf, starId, target, text, testid, through, where, within } from "../harness/flow"; import { get, until, xrpc } from "../harness/net"; import { acceptConfirm, pdsRecordUrl, recall, remember } from "../harness/shared"; const string = record("org.tangled.snippet.string"); const stringFilename = "notes.txt"; const stringBody = "the needle is in the haystack"; const newString = where("the new string form", () => "/strings/new"); const stringPage = where( "the string page", (ctx) => `/strings/${recall(ctx, "writer").handle}/${rkeyOf(ctx, string)}` ); const stringsTab = where( "the writer's strings tab", (ctx) => `/${recall(ctx, "writer").handle}?tab=strings` ); // the editor is a web component, its contenteditable is in a shadow root const stringEditor = target("the string editor", (tab) => tab.locator("diffs-container").getByRole("textbox") ); const typeInEditor = (words: string, replace = false) => async (ctx: Ctx) => { const editor = stringEditor.locate(ctx.page, ctx); const typed = ctx.page.locator( `[data-testid="contentBytes"][data-bytes="${Buffer.byteLength(words)}"]` ); // the editor drops keys when it redraws after the filename changes await until("the editor should hold what was typed", 30_000, async () => { await editor.click(); await ctx.page.waitForTimeout(500); await ctx.page.keyboard.press("ControlOrMeta+a"); if (!replace) await ctx.page.keyboard.press("Backspace"); await editor.pressSequentially(words, { delay: 20 }); const held = await typed.isVisible({ timeout: 1500 }).catch(() => false); return [held, held ? "it does" : "it does not"]; }); }; const starredStringCard = target("the starred string's card", (tab, ctx) => tab.locator( `[data-component="StringCardContent"]:has(a[href="/strings/${recall(ctx, "writer").handle}/${rkeyOf(ctx, string)}"])` ) ); const stringOnPds = async (ctx: Ctx) => { const { value } = await getOk( pdsRecordUrl(recall(ctx, "writer").did, string.nsid, rkeyOf(ctx, string)) ); const blob = await get( xrpc(config.pdsUrl, "com.atproto.sync.getBlob", { did: recall(ctx, "writer").did, cid: value.snippet.content.ref.$link }) ); return { value, contents: blob.body as string }; }; const writtenString = (name: string) => flow(name) .actor("writer") .step("remember the writer", remember("writer")) .open(newString) .fill(testid("filename"), stringFilename) .fill(testid("description"), "my notes") .step("types the content", typeInEditor(stringBody)) .click(testid("submit")) .sends( string, pds, { record: { $type: string.nsid, snippet: { name: stringFilename }, description: "my notes" } }, "put" ) .trace(string, through(pds, hydrant, bobbin)) .at(stringPage) .sees(text(stringBody)); writtenString("creating a string") .step("the pds keeps the text in a blob", async (ctx) => { const { value, contents } = await stringOnPds(ctx); expect(value.snippet.name).toBe(stringFilename); expect(contents).toBe(stringBody); }) .reload() .sees(text(stringBody)) .sees(within(testid("stringDescription"), text("my notes"))) .open(stringsTab) .sees(link(stringFilename)); const edited = "the needle moved to another haystack"; writtenString("editing a string") .click(testid("edit")) .fill(testid("description"), "my edited notes") .step("types the new content", typeInEditor(edited, true)) .click(testid("submit")) .sends( string, pds, replacing(string, { snippet: { name: stringFilename }, description: "my edited notes" }), "put" ) .trace(string, through(pds, hydrant, bobbin)) .sees(text(edited)) .sees(within(testid("stringDescription"), text("my edited notes"))) .step("the pds keeps the new text", async (ctx) => { expect((await stringOnPds(ctx)).contents).toBe(edited); }) .reload() .sees(text(edited)) .sees(not(text(stringBody))); writtenString("deleting a string") .step("agree to the confirm", acceptConfirm) .click(testid("moreMenu")) .click(testid("delete")) .sends(string, pds, replacing(string), "delete") .trace(string, through(pds, hydrant, bobbin)) .at(stringsTab) .sees(not(link(stringFilename))) .open(stringPage) .sees(testid("errorTitle", { status: "404" })); writtenString("starring a string") .actor("fan") .open(stringPage) .click(testid("star", { starred: "false" })) .sends( starId, pds, (ctx) => ({ record: { subject: { $type: "org.tangled.feed.star#record", record: { uri: ctx.get(string).uri, cid: ctx.get(string).cid } } } }), "create" ) .sees(testid("star", { starred: "true" })) .trace(starId, through(pds, hydrant, bobbin)) .reload() .sees(testid("star", { starred: "true" })) .open(where("the fan's starred tab", (ctx) => `/${ctx.actor.handle}?tab=starred`)) .sees(starredStringCard) .sees(within(starredStringCard, link(stringFilename))) .sees(within(starredStringCard, text("my notes")));