Files
kandev/apps/web/scripts/team-access-docs-shots.mjs
João Salavisa 481359ddb2 feat(auth): organization units, tenancy, roles and scopes for team access (#2919)
Co-authored-by: Kandev Agent <agent@kandev.dev>
Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-05 13:40:43 +00:00

176 lines
7.9 KiB
JavaScript

// Capture the documentation screenshots for docs/public/team-access.md from the
// seeded demo instance. Run after scripts/demo-team-access.sh up:
//
// cd apps/web && node scripts/team-access-docs-shots.mjs
import { chromium } from "@playwright/test";
import { mkdirSync } from "node:fs";
const BASE = process.env.KANDEV_DEMO_URL ?? "http://127.0.0.1:8231";
const OUT = process.env.SHOT_DIR ?? "../../docs/screenshots";
const PASSWORD = "kandev-demo-pw-1";
mkdirSync(OUT, { recursive: true });
async function signIn(context, email) {
const page = await context.newPage();
// The hermetic demo has no agent CLIs installed, so the board route would
// otherwise open the onboarding dialog over every shot.
await page.addInitScript(() =>
window.localStorage.setItem("kandev.onboarding.completed", "true"),
);
await page.goto(`${BASE}/login`, { waitUntil: "domcontentloaded" });
await page.getByLabel(/email/i).fill(email);
await page.getByLabel(/password/i).fill(PASSWORD);
await Promise.all([
page.waitForResponse(
(r) => r.url().includes("/api/v1/auth/login") && r.request().method() === "POST",
),
page.getByRole("button", { name: /sign in|log in/i }).click(),
]);
await page.waitForLoadState("networkidle");
// Fail loudly. Without this a wrong password or a seed that uses different
// emails leaves the browser sitting on /login, and every screenshot below
// silently captures the sign-in card over a good committed one.
if (new URL(page.url()).pathname.startsWith("/login")) {
throw new Error(`sign in as ${email} did not leave /login: wrong seed or password?`);
}
for (let i = 0; i < 6; i += 1) {
const skip = page.getByRole("button", { name: /^skip$/i });
if (!(await skip.count())) break;
await skip
.first()
.click()
.catch(() => {});
await page.waitForTimeout(500);
}
return page;
}
// shot captures `focus` when given, and the whole page otherwise.
//
// It used to scroll the focused element into view and then screenshot the
// page, which meant every "card" shot was really a full page: two shots of the
// same route came out byte-identical, and the subject was a small part of a
// tall image. Clipping to the element is the whole point of naming one.
async function shot(page, name, focus) {
await page.waitForTimeout(1200);
if (focus) {
const target = page.locator(focus).first();
if (await target.count()) {
await target.scrollIntoViewIfNeeded();
await page.waitForTimeout(400);
await target.screenshot({ path: `${OUT}/${name}.png` });
console.log(`captured ${name}.png (clipped to ${focus})`);
return;
}
throw new Error(`shot "${name}": focus selector ${focus} matched nothing`);
}
await page.screenshot({ path: `${OUT}/${name}.png` });
console.log(`captured ${name}.png`);
}
// The card itself, not a bottom anchor: shots clip to what they name.
const TEAM_ACCESS_CARD = '[data-testid="workspace-team-access-card"]';
const browser = await chromium.launch();
try {
const desktop = { width: 1440, height: 1250 };
const ana = await signIn(await browser.newContext({ viewport: desktop }), "ana@example.com");
const { workspaces } = await (await ana.request.get(`${BASE}/api/v1/workspaces`)).json();
const team = workspaces.find((w) => w.name === "Platform Team");
// The tree replaced per-workspace visibility, so the first two shots are the
// structure and who is in it.
await ana.goto(`${BASE}/settings/units`, { waitUntil: "networkidle" });
await ana.getByTestId("unit-row").first().waitFor({ state: "visible", timeout: 20000 });
await shot(ana, "team-access-units-tree");
const platformRow = ana.getByTestId("unit-row").filter({ hasText: "Platform" }).first();
await platformRow.getByTestId("unit-members").click();
await ana.getByTestId("unit-members-dialog").waitFor({ state: "visible", timeout: 20000 });
await ana.waitForTimeout(600);
await shot(ana, "team-access-unit-members", '[data-testid="unit-members-dialog"]');
await ana.keyboard.press("Escape");
// Creating a unit, and the placement control that decides who reaches a
// workspace. Placement is the only way to narrow access, so it earns a shot.
await ana.getByTestId("unit-add-child").first().click();
await ana.getByTestId("unit-name-input").waitFor({ state: "visible", timeout: 20000 });
await ana.getByTestId("unit-name-input").fill("Security");
await ana.waitForTimeout(400);
await shot(ana, "team-access-unit-create");
await ana.getByRole("button", { name: /cancel/i }).click();
await ana.goto(`${BASE}/settings/workspace/${team.id}`, { waitUntil: "networkidle" });
await ana.getByTestId("workspace-placement-card").waitFor({ state: "visible", timeout: 20000 });
await shot(ana, "team-access-workspace-placement", '[data-testid="workspace-placement-card"]');
await ana.goto(`${BASE}/settings/workspace/${team.id}`, { waitUntil: "networkidle" });
await shot(ana, "team-access-owner-card", TEAM_ACCESS_CARD);
await ana.goto(`${BASE}/settings/system/users`, { waitUntil: "networkidle" });
await ana.getByTestId("users-table-card").waitFor({ state: "visible", timeout: 20000 });
await shot(ana, "team-access-user-roles");
const bruno = await signIn(await browser.newContext({ viewport: desktop }), "bruno@example.com");
await bruno
.context()
.addCookies([{ name: "kandev-active-workspace", value: team.id, url: BASE }]);
await bruno.goto(`${BASE}/`, { waitUntil: "networkidle" });
await shot(bruno, "team-access-shared-board");
// The human assignee, on both boards. Ana assigns Bruno through the API so
// the shots show a task somebody else owns, which is what the "Assign to me"
// affordance is for.
const tasks = await (await ana.request.get(`${BASE}/api/v1/workspaces/${team.id}/tasks`)).json();
const task = (tasks.tasks ?? tasks)[0];
const { users } = await (await ana.request.get(`${BASE}/api/v1/users/directory`)).json();
const brunoId = users.find((u) => u.display_name.includes("Bruno"))?.id;
if (task && brunoId) {
await ana.request.patch(`${BASE}/api/v1/tasks/${task.id}`, {
data: { assignee_user_id: brunoId },
});
await ana.goto(`${BASE}/t/${task.id}`, { waitUntil: "networkidle" });
await ana.getByTestId("task-assignee-control").waitFor({ state: "visible", timeout: 25000 });
await ana.waitForTimeout(800);
await ana.screenshot({
path: `${OUT}/team-access-kanban-assignee-topbar.png`,
clip: { x: 540, y: 0, width: 900, height: 56 },
});
console.log("captured team-access-kanban-assignee-topbar.png");
// The board shot needs the shared workspace selected; the root route
// otherwise opens whichever workspace the cookie last named.
await ana
.context()
.addCookies([{ name: "kandev-active-workspace", value: team.id, url: BASE }]);
await ana.goto(`${BASE}/`, { waitUntil: "networkidle" });
const badge = ana.getByTestId("kanban-card-assignee").first();
await badge.waitFor({ state: "visible", timeout: 25000 });
const card = badge.locator("xpath=ancestor::*[@data-testid][1]");
await ((await card.count()) ? card : badge).screenshot({
path: `${OUT}/team-access-kanban-assignee-card.png`,
});
console.log("captured team-access-kanban-assignee-card.png");
}
const carla = await signIn(await browser.newContext({ viewport: desktop }), "carla@example.com");
await carla.goto(`${BASE}/settings/workspace/${team.id}`, { waitUntil: "networkidle" });
await shot(carla, "team-access-viewer-card", TEAM_ACCESS_CARD);
const mobile = await signIn(
await browser.newContext({
viewport: { width: 390, height: 844 },
isMobile: true,
hasTouch: true,
deviceScaleFactor: 2,
}),
"ana@example.com",
);
await mobile.goto(`${BASE}/settings/workspace/${team.id}`, { waitUntil: "networkidle" });
await shot(mobile, "team-access-mobile", TEAM_ACCESS_CARD);
console.log(`\nwritten to ${OUT}`);
} finally {
await browser.close();
}