import {readFile} from "node:fs/promises" import type {Page} from "@playwright/test" import {npubEncode, nsecEncode} from "nostr-tools/nip19" import {encrypt} from "nostr-tools/nip49" import {hexToBytes} from "@welshman/lib" import {expect, roomPath, spacePath, test, users} from "../harness" import type {TestUser} from "../harness" // The sign-in gate AppContainer renders in place of the app for anyone without a session. const gate = (page: Page) => page.getByRole("heading", {name: "Welcome to Flotilla!"}) const nsecFor = (user: TestUser) => nsecEncode(hexToBytes(user.secret)) // Landing → "Log in" → "Log in with Key" → paste → submit, which is the only way to watch a // session come into existence: a session injected by `as()` is re-applied on every navigation. const logInWithKey = async (page: Page, key: string) => { await page.getByRole("button", {name: "Log in"}).click() await page.getByRole("button", {name: "Log in with Key"}).click() await page.getByPlaceholder("nsec1...").fill(key) await page.getByRole("button", {name: "Log in", exact: true}).click() } // The nav's settings link carries its label as a tooltip rather than as an accessible name — its // icon is a masked svg with no alt text — so it is addressed by where it goes. const openSettings = (page: Page) => page.locator('.primary-nav a[href="/settings/profile"]').click() // The profile page's Public Key field. A nip01 login also renders a masked Private Key input right // below it, so `getByRole("textbox")` on this page is two elements — the npub is the readonly one // that isn't a password. const npubField = (page: Page) => page.locator('input[readonly]:not([type="password"])') test("US-001 sign-in gate for logged-out visitors", async ({seed, visit}) => { const scenario = await seed(({relay}) => { relay("space").room("general", {name: "General"}) }) const {url} = scenario.space("space") // A room url rather than the root: the gate stands in front of the whole app, so where the // visitor landed makes no difference. const page = await visit(roomPath(url, "general")) await expect(gate(page)).toBeVisible() await expect(page.getByRole("button", {name: "Log in"})).toBeVisible() await expect(page.getByRole("button", {name: "Create an account"})).toBeVisible() await expect(page.getByRole("link", {name: "Terms of Service"})).toHaveAttribute( "href", "https://flotilla.social/terms", ) await expect(page.getByRole("link", {name: "Privacy Policy"})).toHaveAttribute( "href", "https://flotilla.social/privacy", ) await page.keyboard.press("Escape") await expect(gate(page)).toBeVisible() // The backdrop, clicked in its top left corner so the dialog itself isn't what gets hit. await page.getByRole("button", {name: "Close dialog"}).click({position: {x: 4, y: 4}}) await expect(gate(page)).toBeVisible() await page.getByRole("button", {name: "Log in"}).click() await expect(page.getByRole("heading", {name: "Log in with Nostr"})).toBeVisible() await page.goBack() await expect(gate(page)).toBeVisible() await page.getByRole("button", {name: "Create an account"}).click() await expect(page.getByRole("heading", {name: "Join Flotilla"})).toBeVisible() }) test("US-002 sign up by generating a new key", async ({seed, visit}) => { const scenario = await seed(({relay}) => { relay("space").room("general", {name: "General"}) }) const {url} = scenario.space("space") const page = await visit() await page.getByRole("button", {name: "Create an account"}).click() await page.getByRole("button", {name: "Generate a key"}).click() await page.locator('input[type="text"]').fill("Nova Tester") await page.getByRole("button", {name: "Create Account"}).click() await expect(page.getByRole("heading", {name: "Your Keys are Ready!"})).toBeVisible() const continueButton = page.getByRole("button", {name: "Continue"}) const password = page.locator('input[type="password"]') await expect(continueButton).toBeDisabled() await page.getByRole("button", {name: "I want to download an encrypted version"}).click() await password.fill("hunter2") await page.getByRole("button", {name: "Download my key"}).click() await expect(page.getByRole("alert")).toContainText( "Your password must be at least 12 characters long.", ) await expect(continueButton).toBeDisabled() await password.fill("correct horse battery staple") const download = page.waitForEvent("download") await page.getByRole("button", {name: "Download my key"}).click() const contents = await readFile(await (await download).path(), "utf8") expect(contents).toContain("ncryptsec1") expect(contents).not.toContain("nsec1") await expect(continueButton).toBeEnabled() await continueButton.click() await expect(page.getByRole("heading", {name: "You're all set!"})).toBeVisible() await page.getByRole("button", {name: "Go to Dashboard"}).click() await expect(page).toHaveURL(/\/home$/) await expect(page.locator(".primary-nav")).toBeVisible() await expect(page.getByRole("heading", {name: "You're all set!"})).toHaveCount(0) await expect(gate(page)).toHaveCount(0) // A space's nav item is a button rather than a link — PrimaryNavItemSpace passes an onclick, and // PrimaryNavItem renders a Button whenever it has one — so it is addressed by the tooltip it // carries, which is the relay's nip-11 name, and clicking it is what says which space it is. const spaceItem = page.locator('.primary-nav [data-tip="space"]') await expect(spaceItem).toBeVisible() await spaceItem.click() await expect(page).toHaveURL(new RegExp(spacePath(url))) await openSettings(page) await expect(page.getByText("Nova Tester")).toBeVisible() }) test("US-003 log in with an existing private key", async ({seed, visit}) => { await seed(({relay}) => { relay("space").room("general", {name: "General"}) }) const aliceNpub = npubEncode(users.alice.pubkey) const password = "a very good password" const ncryptsec = encrypt(hexToBytes(users.alice.secret), password) const withNsec = await visit() await logInWithKey(withNsec, nsecFor(users.alice)) await expect(withNsec.locator(".primary-nav")).toBeVisible() await openSettings(withNsec) await expect(npubField(withNsec)).toHaveValue(aliceNpub) const withHex = await visit() await logInWithKey(withHex, users.alice.secret) await expect(withHex.locator(".primary-nav")).toBeVisible() await openSettings(withHex) await expect(npubField(withHex)).toHaveValue(aliceNpub) const withNcryptsec = await visit() await withNcryptsec.getByRole("button", {name: "Log in"}).click() await withNcryptsec.getByRole("button", {name: "Log in with Key"}).click() const key = withNcryptsec.getByPlaceholder("nsec1...") const submit = withNcryptsec.getByRole("button", {name: "Log in", exact: true}) await key.fill("this is not a key") await expect(submit).toBeDisabled() await key.fill(ncryptsec) await withNcryptsec.getByPlaceholder("Your password").fill("not the password") await submit.click() await expect(withNcryptsec.getByRole("alert")).toContainText( "Failed to decrypt key. Please check your password.", ) await expect(withNcryptsec.getByRole("heading", {name: "Log In with Key"})).toBeVisible() await expect(withNcryptsec.locator(".primary-nav")).toHaveCount(0) await withNcryptsec.getByPlaceholder("Your password").fill(password) await submit.click() await expect(withNcryptsec.locator(".primary-nav")).toBeVisible() await openSettings(withNcryptsec) await expect(npubField(withNcryptsec)).toHaveValue(aliceNpub) // A second browser context with its own storage: bob's session is his own, not a second view // of alice's. const asBob = await visit() await logInWithKey(asBob, nsecFor(users.bob)) await openSettings(asBob) await expect(npubField(asBob)).toHaveValue(npubEncode(users.bob.pubkey)) }) test("US-004 log in with a browser extension", async ({seed, visit}) => { await seed(({relay}) => { relay("space").room("general", {name: "General"}) }) const page = await visit("/", {nip07: users.alice}) await page.getByRole("button", {name: "Log in"}).click() await page.getByRole("button", {name: "Log in with Extension"}).click() await expect(page.locator(".primary-nav")).toBeVisible() await openSettings(page) await expect(page.getByRole("textbox")).toHaveValue(npubEncode(users.alice.pubkey)) await expect(page.locator('input[type="password"]')).toHaveCount(0) const withoutExtension = await visit() await withoutExtension.getByRole("button", {name: "Log in"}).click() await expect( withoutExtension.getByRole("button", {name: "Log in with Remote Signer"}), ).toBeVisible() await expect(withoutExtension.getByRole("button", {name: "Log in with Extension"})).toHaveCount(0) }) test("US-005 log in with a remote signer", async ({seed, visit}) => { await seed(({relay}) => { relay("space").room("general", {name: "General"}) }) const page = await visit() await page.getByRole("button", {name: "Log in"}).click() await page.getByRole("button", {name: "Log in with Remote Signer"}).click() const bunker = page.getByPlaceholder("bunker://") const next = page.getByRole("button", {name: "Next"}) // The relay named here belongs to no scenario, so a connection attempt would be recorded as a // leak and fail this test rather than passing unnoticed. await bunker.fill("bunker://not-a-signer-pubkey?relay=wss://nowhere.test/") await next.click() await expect(page.getByRole("alert")).toContainText( "Sorry, it looks like that's an invalid bunker link.", ) await bunker.fill(`bunker://${users.carol.pubkey}`) await next.click() await expect(page.getByRole("alert")).toContainText( "That bunker link does not include any relays.", ) await page.getByRole("button", {name: "Log in with a QR code instead"}).click() await expect(page.getByText("Scan with your signer to log in, or click to copy.")).toBeVisible() await page.getByRole("button", {name: "Go back"}).click() await expect(bunker).toBeVisible() }) test("US-006 stay logged in, and log out deliberately", async ({seed, visit}) => { await seed(({relay, user}) => { const space = relay("space") space.room("general", {name: "General"}) space.join(user.alice, "general") }) const npub = npubEncode(users.alice.pubkey) const page = await visit() await logInWithKey(page, nsecFor(users.alice)) await expect(page.locator(".primary-nav")).toBeVisible() await openSettings(page) await expect(npubField(page)).toHaveValue(npub) await page.reload() await expect(page.locator(".primary-nav")).toBeVisible() await expect(gate(page)).toHaveCount(0) await expect(npubField(page)).toHaveValue(npub) await page.locator(".secondary-nav").getByRole("button", {name: "Log Out"}).click() await expect(page.getByRole("heading", {name: /Are you sure you want/})).toBeVisible() await expect(page.getByText("Your local database will be cleared.")).toBeVisible() await page.getByRole("button", {name: "Go back"}).click() await expect(page.locator(".primary-nav")).toBeVisible() await expect(npubField(page)).toHaveValue(npub) await page.locator(".secondary-nav").getByRole("button", {name: "Log Out"}).click() await page.locator("form").getByRole("button", {name: "Log Out"}).click() await expect(gate(page)).toBeVisible() }) test("US-007 inspect your keys and signer status", async ({seed, as, visit}) => { await seed(({relay, user}) => { const space = relay("space") space.room("general", {name: "General"}) space.join(user.alice, "general") }) const npub = npubEncode(users.alice.pubkey) const page = await as(users.alice, "/settings/profile", { context: {permissions: ["clipboard-read", "clipboard-write"]}, }) const readClipboard = () => page.evaluate(() => navigator.clipboard.readText()) const npubInput = npubField(page) await expect(npubInput).toHaveValue(npub) await expect(npubInput).toHaveJSProperty("readOnly", true) await page.locator("label.input").filter({has: npubInput}).getByRole("button").click() await expect.poll(readClipboard).toBe(npub) const secretInput = page.locator('input[type="password"]') await expect(secretInput).toHaveValue(users.alice.secret) await page.locator("label.input").filter({has: secretInput}).getByRole("button").click() await expect.poll(readClipboard).toBe(nsecFor(users.alice)) const signerStatus = page.locator(".card").filter({hasText: "Signer Status"}).last() await expect(signerStatus).toContainText("Logged in with private key") await expect(signerStatus).toContainText("Ok") const succeeded = async () => Number(/(\d+) requests succeeded/.exec(await signerStatus.innerText())?.[1]) const before = await succeeded() await page.locator(".card").first().getByRole("button").click() await page.locator('input[type="text"]').first().fill("Alice Anderson") await page.getByRole("button", {name: "Save Changes"}).click() await expect(page.getByRole("alert")).toContainText("Your profile has been updated!") await expect.poll(succeeded).toBeGreaterThan(before) // An extension session holds no key for the app to show. const extension = await visit("/", {nip07: users.bob}) await extension.getByRole("button", {name: "Log in"}).click() await extension.getByRole("button", {name: "Log in with Extension"}).click() await expect(extension.locator(".primary-nav")).toBeVisible() await openSettings(extension) await expect(extension.getByRole("textbox")).toHaveValue(npubEncode(users.bob.pubkey)) await expect(extension.locator('input[type="password"]')).toHaveCount(0) await expect(extension.locator(".card").filter({hasText: "Signer Status"}).last()).toContainText( "Logged in with browser extension", ) }) test("US-008 delete your nostr account", async ({seed, as, visit}) => { await seed(({relay, user}) => { const space = relay("space") space.room("general", {name: "General"}) space.join(user.alice, "general") space.join(user.bob, "general") space.profile(user.alice, {name: "Alice Anderson"}) }) const alice = await visit() await logInWithKey(alice, nsecFor(users.alice)) await openSettings(alice) await alice.locator(".card").filter({hasText: "Advanced"}).getByRole("button").click() await alice.getByRole("button", {name: "Delete your profile"}).click() await expect(alice.getByRole("heading", {name: "Delete your account"})).toBeVisible() const confirm = alice.getByRole("button", {name: "Confirm"}) const phrase = alice.locator('input[type="text"]') await expect(confirm).toBeDisabled() await phrase.fill("permanently delete my") await expect(confirm).toBeDisabled() await phrase.fill("permanently delete my nostr account") await expect(confirm).toBeEnabled() await confirm.click() await expect(alice.getByRole("progressbar")).toBeVisible() await expect(gate(alice)).toBeVisible() const bob = await as(users.bob, `/people/${npubEncode(users.alice.pubkey)}`) // zooid honours the kind-62 right-to-vanish, so alice's profile — including the "[deleted]" name // the app blanks it to first — is gone rather than renamed. Bob sees the npub fallback the app // shows for anyone with no profile: displayPubkey, which is npub.slice(0,8)+"…"+npub.slice(-5). const aliceNpub = npubEncode(users.alice.pubkey) const fallback = aliceNpub.slice(0, 8) + "…" + aliceNpub.slice(-5) await expect(bob.getByRole("heading", {name: fallback})).toBeVisible() await expect(bob.getByText("No notes found for this profile.")).toBeVisible() })