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")));