Files
docker-images/timepoll/tests/browser.cjs
T
ajp_anton 4cc2e8fc80
Build custom container images / build (map[base_image:php:8-fpm-alpine build_args:PHP_VERSION=8 context:php8-pgsql fingerprint_command:{ apk info -v | LC_ALL=C sort; find /usr/local/lib/php/extensions /usr/local/etc/php/conf.d -type f -exec sha256sum {} + | LC_ALL=C sort; } name:ph… (push) Successful in 49s
Build custom container images / build (map[base_image:postgres:18 build_args:PG_VERSION=18 POSTGIS_VERSION=3 VCHORD_VERSION=0.5.3 context:postgres fingerprint_command:{ dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; find /usr/lib/postgresql -type f -exec sha256su… (push) Successful in 1m42s
Build custom container images / build (map[base_image:python:3 build_args:PYTHON_VERSION=3 context:python-tools fingerprint_command:{ dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; pip freeze | LC_ALL=C sort; } name:python-tools oci_labels:org.opencontainers.ima… (push) Successful in 54s
Build custom container images / build (map[base_image:python:3.12-slim build_args:PYTHON_VERSION=3.12-slim context:linkki-tiedotus fingerprint_command:{ dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; pip freeze | LC_ALL=C sort; } name:linkki-tiedotus oci_labels:… (push) Successful in 29s
Build custom container images / build (map[base_image:python:3.12-slim build_args:PYTHON_VERSION=3.12-slim context:timepoll fingerprint_command:{ dpkg-query -W -f='${binary:Package}=${Version}\n' | LC_ALL=C sort; pip freeze | LC_ALL=C sort; } name:timepoll oci_labels:org.opencontai… (push) Successful in 44s
Add Timepoll availability polling application and image
2026-09-09 19:39:40 +00:00

311 lines
12 KiB
JavaScript

// 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);
});