import { type BrowserContext, expect, test } from "@playwright/test";
import { readFileSync } from "node:fs";
import { type Account, authFile, config, seeded } from "../harness/config";
import {
bobbin,
component,
type Ctx,
flow,
hydrant,
knots,
loginThen,
page,
pds,
starId,
target,
testid,
through,
where,
within
} from "../harness/flow";
import { parseBody, stamp } from "../harness/net";
import { signInHere } from "../harness/signin";
import { forgetTheLogin, recall, remember } from "../harness/shared";
const menuOf = (who: (ctx: Ctx) => Account) =>
target("the account menu", (tab, ctx) =>
tab.getByRole("banner").getByRole("button", { name: who(ctx).handle })
);
const actorMenu = menuOf((ctx) => ctx.actor);
const logout = testid("signOut");
const otherOf = (ctx: Ctx) => recall(ctx, "other");
const signInFromHere = (who: (ctx: Ctx) => Account) => (ctx: Ctx) => signInHere(ctx.page, who(ctx));
const signInAsActor = signInFromHere((ctx) => ctx.actor);
const browserState = (ctx: Ctx) =>
ctx.page.evaluate(() => ({
current: localStorage.getItem("tangled.currentDid"),
sessions: localStorage.getItem("atcute-oauth:sessions") ?? "",
accounts: localStorage.getItem("tangled.accounts") ?? ""
}));
// dids become "did" so two logins compare equal
const dropDids = (value: unknown): unknown => {
if (Array.isArray(value)) return value.map(dropDids);
if (!value || typeof value !== "object") return typeof value;
return Object.fromEntries(
Object.entries(value).map(([key, inner]) => [
key.startsWith("did:") ? "did" : key,
dropDids(inner)
])
);
};
const kept = (state: Awaited<ReturnType<BrowserContext["storageState"]>>) => ({
cookies: state.cookies
.map(
(c) =>
`${c.domain} ${c.name} ${c.httpOnly} ${c.secure} ${c.sameSite} ${c.expires === -1}`
)
.sort(),
storage: state.origins.map(({ origin, localStorage }) => ({
origin,
items: Object.fromEntries(
localStorage.map(({ name, value }) => [name, dropDids(parseBody(value))])
)
}))
});
const keptLikeALogin = async (ctx: Ctx) => {
const login = JSON.parse(readFileSync(authFile(seeded.alice), "utf8"));
expect(kept(await ctx.page.context().storageState())).toEqual(kept(login));
};
const firstStar = (name: string, options: { login?: "ui" } = {}) =>
flow(name)
.actor("owner")
.repo()
.actor("fan", options)
.open(page.repo)
.sees(actorMenu)
.step("the browser keeps the session the way a login does", keptLikeALogin)
.click(testid("star", { starred: "false" }))
.sends(
starId,
pds,
(ctx) => ({ record: { subject: { identity: ctx.repo.did } } }),
"create"
)
.trace(starId, through(pds, hydrant, bobbin))
.reload()
.sees(testid("star", { starred: "true" }));
firstStar("a new account logged in through the login page stars a repo", { login: "ui" });
firstStar("a new account with a session minted from node stars a repo");
flow("logging out of one of two accounts in a browser keeps the other signed in")
.actor("second")
.step("remember the second account", remember("other"))
.actor("first", { login: "ui" })
.open(knots)
.click(actorMenu)
.click(testid("addAccount"))
.at(loginThen(() => "/settings/knots"))
.step("the second account signs in beside the first", signInFromHere(otherOf))
.at(knots)
.sees(menuOf(otherOf))
.click(menuOf(otherOf))
.sees(logout)
.click(logout)
.sees(actorMenu)
.step("the browser keeps the first account and has let go of the second", async (ctx) => {
const saw = await browserState(ctx);
expect(saw.current).toBe(ctx.actor.did);
expect(saw.sessions).toContain(ctx.actor.did);
expect(saw.sessions).not.toContain(otherOf(ctx).did);
expect(saw.accounts).not.toContain(otherOf(ctx).did);
})
.reload()
.sees(actorMenu)
.open(knots)
.at(knots);
flow("logging out ends the session and a settings page asks for a login again")
.actor("leaver", { login: "ui" })
.open(page.profile)
.step("the page is signed in", async (ctx) => {
expect((await browserState(ctx)).current).toBe(ctx.actor.did);
})
.click(actorMenu)
.click(logout)
.sees(within(component("Topbar"), testid("login")))
.step("the browser has forgotten the account", async (ctx) => {
const saw = await browserState(ctx);
expect(saw.current).toBeNull();
expect(saw.sessions).not.toContain(ctx.actor.did);
const cookies = await ctx.page.context().cookies();
expect(cookies.find((cookie) => cookie.name === "tangled.currentDid")).toBeUndefined();
})
.open(knots)
.at(loginThen(() => "/settings/knots"));
flow("switching to the other account in the menu changes who the page is signed in as")
.actor("second")
.step("remember the second account", remember("other"))
.actor("first", { login: "ui" })
.open(knots)
.click(actorMenu)
.click(testid("addAccount"))
.step("the second account signs in beside the first", signInFromHere(otherOf))
.at(knots)
.sees(menuOf(otherOf))
.click(menuOf(otherOf))
.click(
target("the first account in the menu", (tab, ctx) =>
tab.getByRole("menuitem").filter({ hasText: ctx.actor.handle })
)
)
.sees(actorMenu)
.step(
"the browser says the first account is the one in use and still has both",
async (ctx) => {
const saw = await browserState(ctx);
expect(saw.current).toBe(ctx.actor.did);
expect(saw.sessions).toContain(otherOf(ctx).did);
const cookies = await ctx.page.context().cookies();
expect(cookies.find((cookie) => cookie.name === "tangled.currentDid")?.value).toContain(
ctx.actor.did.replace(/:/g, "%3A")
);
}
)
.reload()
.sees(actorMenu);
const followeeProfile = where("the followee's profile", (ctx) => `/${otherOf(ctx).handle}`);
flow("a visitor who clicks Follow is sent to log in and comes back to that profile")
.actor("star")
.step("remember who is followed", remember("other"))
.actor("fan", { login: "ui" })
.open(followeeProfile)
.step("signs out", forgetTheLogin)
.open(followeeProfile)
.click(testid("follow", { following: "false" }))
.at(loginThen((ctx) => `/${otherOf(ctx).handle}`))
.step("signs back in", signInAsActor)
.at(followeeProfile)
.sees(testid("follow", { following: "false" }));
flow("a visitor who clicks Star is sent to log in and comes back to that repo")
.actor("owner")
.repo()
.actor("reader", { login: "ui" })
.open(page.repo)
.step("signs out", forgetTheLogin)
.open(page.repo)
.click(testid("star", { starred: "false" }))
.at(loginThen((ctx) => ctx.repo.path))
.step("signs back in", signInAsActor)
.at(page.repo)
.sees(testid("star", { starred: "false" }));
flow("a visitor who opens a repo's settings is sent to log in and comes back to them")
.actor("owner", { login: "ui" })
.repo()
.open(page.repo)
.step("signs out", forgetTheLogin)
.open(page.settings)
.at(loginThen((ctx) => `${ctx.repo.path}/settings`))
.step("signs back in", signInAsActor)
.at(page.settings)
.sees(testid("settingsSectionTitle", { name: "danger" }));
const knotsWithQuery = where("the knots settings with a query", () => "/settings/knots?from=email");
flow("a visitor who opens the knot settings is sent to log in and comes back to them")
.actor("reader", { login: "ui" })
.open(knots)
.step("signs out", forgetTheLogin)
.open(knotsWithQuery)
.at(loginThen(() => "/settings/knots?from=email"))
.step("signs back in", signInAsActor)
.at(knotsWithQuery)
.sees(testid("settingsHeaderTitle", { name: "knots" }));
const inviteHost = "knot.invited.example.test";
const invite = where("the invite page", (ctx) => `/invite?knot=${inviteHost}&code=${code(ctx)}`);
const codes = new WeakMap<Ctx, string>();
// the page only reads the code, so a made up one is fine until Join
const pickCode = async (ctx: Ctx) => {
codes.set(ctx, `e2e-${stamp()}`);
};
const code = (ctx: Ctx) => codes.get(ctx)!;
flow("an invite link opened signed out logs you in and lands on the join page")
.actor("guest", { login: "ui" })
.step("pick a code", pickCode)
.open(page.profile)
.step("signs out", forgetTheLogin)
.open(invite)
.at(loginThen((ctx) => `/invite?knot=${inviteHost}&code=${code(ctx)}`))
.step("signs in", signInAsActor)
.at(invite)
.sees(testid("joinHeading", { knot: inviteHost }))
.sees(testid("join"));
test("the login page shows an error when it is opened with one", async ({ page: tab }) => {
await tab.goto("/login?error=session", { waitUntil: "networkidle" });
await expect(tab.locator('[data-testid="loginErrorHeading"]')).toBeVisible();
});
test("a handle that does not exist is told so on the login page and stays there", async ({
page: tab
}) => {
await tab.goto("/login", { waitUntil: "networkidle" });
const handle = tab.getByTestId("identifier");
await handle.fill(`nobody-${stamp()}.pds.tngl.boltless.dev`);
await handle.press("Enter");
await expect(tab.locator('[data-testid="loginErrorHeading"]')).toBeVisible();
await expect(tab.locator('[data-testid="loginErrorMessage"]')).toBeVisible();
await expect(tab).toHaveURL(`${config.webUrl}/login`);
});
const guarded = [
"/welcome",
"/strings/new",
"/organizations/new",
"/settings/profile/delete?step=1",
"/settings/keys/new"
];
for (const path of guarded) {
test(`a visitor opening ${path} is sent to log in with that page to come back to`, async ({
page: tab
}) => {
await tab.goto(path, { waitUntil: "networkidle" });
await expect(tab).toHaveURL(
`${config.webUrl}/login?return_url=${encodeURIComponent(path)}`
);
});
}