// Run against an isolated local database, not an existing production instance. const { chromium } = require("playwright"); const assert = require("node:assert/strict"); const base = process.env.TIMEPOLL_TEST_URL || "http://127.0.0.1:8094"; (async () => { const browser = await chromium.launch({ headless: true, executablePath: process.env.CHROMIUM_PATH, }); const desktop = await browser.newContext({ viewport: { width: 1440, height: 1050 }, timezoneId: "Europe/Helsinki", }); const errors = []; desktop.on("page", (p) => p.on("pageerror", (error) => errors.push(error.message)), ); const page = await desktop.newPage(); const clipboardContext = await browser.newContext({ permissions: ["clipboard-read", "clipboard-write"], }); const clipboardPage = await clipboardContext.newPage(); const clipboardOrigin = new URL(base); clipboardOrigin.hostname = "127.0.0.1"; await clipboardPage.goto(clipboardOrigin.origin + "/healthz"); async function checkCopy(button, expected) { await page.bringToFront(); if (await page.locator("#notice").isVisible()) await page.getByRole("button", { name: "Dismiss" }).click(); await button.click(); await page.getByRole("alert").filter({ hasText: "Link copied." }).waitFor(); await clipboardPage.bringToFront(); assert.equal( await clipboardPage.evaluate(() => navigator.clipboard.readText()), expected, ); await page.bringToFront(); } await page.goto(base + "/"); await page.getByRole("heading", { name: "Timepoll", exact: true }).waitFor(); await page.screenshot({ path: "/tmp/timepoll-home.png", fullPage: true }); await page.getByRole("link", { name: "Create poll", exact: true }).click(); await page.getByLabel("Event name").fill("Autumn weekend"); assert.equal( await page.locator("#event-zone").inputValue(), "Europe/Helsinki", ); await page.locator("#date-from").fill("2026-10-23"); await page.locator("#date-to").fill("2026-10-26"); assert.equal(await page.locator("#date-count").textContent(), "0 added"); await page.getByRole("button", { name: "Apply dates and times" }).click(); await page .getByRole("alert") .filter({ hasText: "Add the pending dates" }) .waitFor(); assert.equal(await page.locator(".slot").count(), 0); await page.getByRole("button", { name: "Dismiss" }).click(); await page.getByRole("button", { name: "Add dates", exact: true }).click(); assert.equal(await page.locator("#date-count").textContent(), "4 added"); assert.equal(await page.locator("#date-from").inputValue(), ""); assert.equal(await page.locator("#start-time option").count(), 48); assert.equal(await page.locator("#end-time option").count(), 48); await page.locator("#slot-size").selectOption("15"); await page.locator("#start-time").selectOption("09:15"); await page.locator("#end-time").selectOption("16:45"); await page.locator("#slot-size").selectOption("30"); assert.equal(await page.locator("#start-time").inputValue(), "09:00"); assert.equal(await page.locator("#end-time").inputValue(), "17:00"); await page.getByRole("button", { name: "Apply dates and times" }).click(); await page.locator(".slot").first().waitFor(); assert.equal(await page.locator(".slot").count(), 64); assert.equal(await page.locator("tr.hour-mark").count(), 8); assert.equal( await page .locator("tr.hour-mark .time") .first() .evaluate((e) => getComputedStyle(e).fontWeight), "700", ); await page.locator(".slot").first().click(); assert.equal(await page.locator(".slot.blocked").count(), 1); await page.screenshot({ path: "/tmp/timepoll-editor.png", fullPage: true }); await page.getByRole("button", { name: "Create poll", exact: true }).click(); const publicLink = await page.getByLabel("Public voting link").inputValue(); const adminLink = await page.getByLabel("Private admin link").inputValue(); assert.match( new URL(publicLink).pathname, /^\/[A-Z0-9]{3}-[A-Z0-9]{3}-[A-Z0-9]{3}$/, ); assert.match( new URL(adminLink).pathname, /^\/[A-Z0-9]{4}-[A-Z0-9]{4}-[A-Z0-9]{4}$/, ); assert.equal(new URL(adminLink).hash, ""); await checkCopy( page.getByRole("button", { name: "Copy private link" }), adminLink, ); await checkCopy( page.getByRole("button", { name: "Copy public link" }), publicLink, ); await page.getByRole("link", { name: "Open poll" }).click(); await checkCopy( page.getByRole("button", { name: "Copy public link" }), publicLink, ); await page.getByLabel("Your name").fill("Alice"); await page.getByRole("button", { name: "Add availability" }).click(); // Drag one contiguous day, without replacing the DOM mid-gesture. const available = page.locator(".slot:not([disabled])"); const a = await available.nth(0).boundingBox(); const b = await available.nth(8).boundingBox(); await page.mouse.move(a.x + a.width / 2, a.y + a.height / 2); await page.mouse.down(); await page.mouse.move(b.x + b.width / 2, b.y + b.height / 2, { steps: 15 }); await page.mouse.up(); await page.getByRole("button", { name: "Maybe", exact: true }).click(); await available.nth(3).click(); await page.getByRole("button", { name: "Save votes" }).click(); await page.getByRole("button", { name: "Add availability" }).waitFor(); assert.equal( await page.locator("#everyone").getAttribute("class"), "person active", ); await page.getByRole("button", { name: "Everyone", exact: true }).click(); await page.getByLabel("Your name").fill("alice"); await page.getByRole("button", { name: "Add availability" }).click(); await page .getByRole("alert") .filter({ hasText: "already in this poll" }) .waitFor(); await page.getByRole("button", { name: "Dismiss" }).click(); const other = await desktop.newPage(); await other.goto(publicLink); await other.getByLabel("Your name").fill("Bob"); await other.getByRole("button", { name: "Add availability" }).click(); const shadow = other.locator(".slot:not([disabled])").first(); assert.match( await shadow.getAttribute("aria-label"), /Other participants: 1 yes, 0 maybe out of 1/, ); assert.ok( await shadow.evaluate((e) => e.style.getPropertyValue("--peer-cell")), ); const boundarySlot = Number( await other .locator(".peer-boundary:not([disabled])") .first() .getAttribute("data-slot"), ); await other.locator(`[data-slot="${boundarySlot - 1800}"]`).click(); await other.locator(`[data-slot="${boundarySlot}"]`).click(); const peerBoundary = other.locator(`[data-slot="${boundarySlot}"]`); assert.ok( (await peerBoundary.getAttribute("class")).includes("peer-boundary"), ); assert.equal( await peerBoundary.evaluate((e) => getComputedStyle(e).borderTopWidth), "2px", ); assert.equal( await peerBoundary.evaluate((e) => getComputedStyle(e, "::after").opacity), "0.35", ); await other.screenshot({ path: "/tmp/timepoll-vote-shadow.png", fullPage: true, }); await other.emulateMedia({ colorScheme: "dark" }); await other.screenshot({ path: "/tmp/timepoll-vote-shadow-dark.png", fullPage: true, }); await other.emulateMedia({ colorScheme: "light" }); await other.locator(".slot:not([disabled])").first().click(); await other.getByRole("button", { name: "Save votes" }).click(); await page .getByRole("button", { name: "Bob", exact: true }) .waitFor({ timeout: 10000 }); await page.getByRole("button", { name: "Everyone", exact: true }).click(); await page.locator(".slot:not([disabled])").first().hover(); await page.locator("#popover").waitFor({ state: "visible" }); await page.locator("#poll-title").hover(); await page.locator("#popover").waitFor({ state: "hidden" }); await page.locator(".slot:not([disabled])").first().hover(); await page.locator("#popover").waitFor({ state: "visible" }); await page.evaluate(() => window.scrollBy(0, 100)); await page.locator("#popover").waitFor({ state: "hidden" }); await page.locator(".slot:not([disabled])").first().click(); await page.locator("#popover").waitFor({ state: "visible" }); assert.equal(await page.locator("#popover .voter").count(), 2); await page.locator("#popover .voter").filter({ hasText: "Bob" }).click(); await page.getByRole("button", { name: "Edit votes" }).click(); assert.match( await page .locator(".slot:not([disabled])") .first() .getAttribute("aria-label"), /Other participants: 1 yes, 0 maybe out of 1/, ); // Another editor updates the same participant. This draft must not win. await other.getByRole("button", { name: "Bob", exact: true }).click(); await other.getByRole("button", { name: "Edit votes" }).click(); await other.getByRole("button", { name: "Save votes" }).click(); await page .getByRole("alert") .filter({ hasText: "changed elsewhere" }) .waitFor({ timeout: 10000 }); await page.getByRole("button", { name: "Save votes" }).click(); await page .getByRole("alert") .filter({ hasText: "changed or removed elsewhere" }) .waitFor(); await page.getByRole("button", { name: "Cancel", exact: true }).click(); assert.equal( await page.locator("#everyone").getAttribute("class"), "person active", ); await page.getByRole("button", { name: "Add availability" }).waitFor(); await page.getByRole("button", { name: "Dismiss" }).click(); await page.screenshot({ path: "/tmp/timepoll-desktop.png", fullPage: true }); // Admin timezone change must retain UTC slots and update editable clocks. const admin = await desktop.newPage(); await admin.goto(adminLink); const initial = await admin .locator(".slot") .first() .getAttribute("data-slot"); await admin.locator("#event-zone").selectOption("America/New_York"); assert.equal( await admin.locator(".slot").first().getAttribute("data-slot"), initial, ); assert.equal(await admin.locator("#start-time").inputValue(), "02:00"); await admin.getByRole("button", { name: "Save changes" }).click(); await admin.getByRole("alert").filter({ hasText: "Poll saved" }).waitFor(); await admin.locator("#slot-size").selectOption("15"); await admin.locator(".grid.compact").waitFor(); assert.ok((await admin.locator("tr.hour-mark").count()) > 0); assert.ok((await admin.locator(".slot").first().boundingBox()).height <= 21); await admin.getByRole("button", { name: "Save changes" }).click(); await admin.getByRole("alert").filter({ hasText: "Poll saved" }).waitFor(); const mobileContext = await browser.newContext({ viewport: { width: 390, height: 844 }, isMobile: true, hasTouch: true, colorScheme: "dark", timezoneId: "America/Los_Angeles", }); mobileContext.on("page", (p) => p.on("pageerror", (error) => errors.push(error.message)), ); const mobile = await mobileContext.newPage(); await mobile.goto(publicLink); await mobile.locator(".slot").first().waitFor(); assert.equal( await mobile.evaluate( () => document.documentElement.scrollWidth <= innerWidth, ), true, ); await mobile.locator(".slot:not([disabled])").first().tap(); await mobile.locator("#popover").waitFor({ state: "visible" }); await mobile.getByRole("button", { name: "Close", exact: true }).tap(); await mobile.screenshot({ path: "/tmp/timepoll-mobile-dark.png", fullPage: true, }); await mobile.getByLabel("Your name").fill("Casey"); await mobile.getByRole("button", { name: "Add availability" }).tap(); await mobile.locator(".slot:not([disabled])").first().tap(); await mobile.getByRole("button", { name: "Save votes" }).tap(); await mobile.getByRole("button", { name: "Add availability" }).waitFor(); await page .getByRole("button", { name: "Casey", exact: true }) .waitFor({ timeout: 10000 }); await mobile.setViewportSize({ width: 320, height: 800 }); await mobile.getByRole("button", { name: "Casey", exact: true }).tap(); await mobile.getByRole("button", { name: "Edit votes" }).tap(); assert.equal( await mobile.evaluate(() => document.documentElement.scrollWidth), 320, ); await mobile.getByRole("button", { name: "Pan grid" }).tap(); assert.equal(await mobile.locator(".grid.editing").count(), 0); await mobile.getByRole("button", { name: "Cancel", exact: true }).tap(); assert.equal( await mobile.locator("#everyone").getAttribute("class"), "person active", ); await mobileContext.close(); assert.deepEqual(errors, []); console.log( JSON.stringify({ publicLink, adminLink: "[private]", screenshots: "/tmp/timepoll-*.png", browserErrors: errors, }), ); await browser.close(); })().catch((error) => { console.error(error); process.exit(1); });