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
311 lines
12 KiB
JavaScript
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);
|
|
});
|