481359ddb2
Co-authored-by: Kandev Agent <agent@kandev.dev> Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
176 lines
7.9 KiB
JavaScript
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();
|
|
}
|