764 lines
26 KiB
JavaScript
764 lines
26 KiB
JavaScript
import test from "node:test";
|
||
import assert from "node:assert/strict";
|
||
import { once } from "node:events";
|
||
import { readFile } from "node:fs/promises";
|
||
import { setup } from "./helpers.js";
|
||
import { blankPlan } from "../shared/fields.js";
|
||
import { renderReportHtml } from "../shared/report.js";
|
||
|
||
const browserOptions = { skip: process.env.RUN_BROWSER_TESTS !== "1" };
|
||
async function session(t, appOptions = {}) {
|
||
const { chromium } = await import("playwright");
|
||
const { app, db } = await setup({}, appOptions);
|
||
const server = app.listen(0, "127.0.0.1");
|
||
await once(server, "listening");
|
||
t.after(() => new Promise((r) => server.close(r)));
|
||
const browser = await chromium.launch({
|
||
headless: true,
|
||
args: ["--no-sandbox"],
|
||
});
|
||
t.after(() => browser.close());
|
||
const page = await browser.newPage({
|
||
viewport: { width: 1440, height: 1100 },
|
||
});
|
||
const errors = [];
|
||
page.on("pageerror", (e) => errors.push(e.message));
|
||
const base = `http://127.0.0.1:${server.address().port}`;
|
||
const res = await page.request.post(base + "/api/auth/signup", {
|
||
data: {
|
||
email: "[email protected]",
|
||
password: "browser regression password",
|
||
},
|
||
});
|
||
assert.equal(res.status(), 201);
|
||
const { csrfToken } = await res.json();
|
||
const create = async (plan, name = "") =>
|
||
(
|
||
await (
|
||
await page.request.post(base + "/api/plans", {
|
||
headers: { "x-csrf-token": csrfToken },
|
||
data: { plan, name },
|
||
})
|
||
).json()
|
||
).plan;
|
||
return { page, browser, base, create, errors, db, csrfToken };
|
||
}
|
||
function warningPlan() {
|
||
const p = blankPlan();
|
||
Object.assign(p.fields, {
|
||
0.1: "Burundi Kayanza Masha — Anaerobic Natural Bourbon",
|
||
0.4: "500",
|
||
1.1: "Bourbon",
|
||
1.4: "8:45",
|
||
3.1: "anaerobic",
|
||
4.1: "light",
|
||
4.2: "12",
|
||
4.3: "1:38",
|
||
3.2: "-0:30",
|
||
1.7: "-0:05",
|
||
5.2: "76.14",
|
||
});
|
||
return p;
|
||
}
|
||
|
||
test(
|
||
"browser: warning-rich reports print on two actual PDF pages without a footer-only page",
|
||
browserOptions,
|
||
async (t) => {
|
||
const { PDFDocument } = await import("pdf-lib");
|
||
const { page, base, create, errors } = await session(t);
|
||
const plan = warningPlan();
|
||
const saved = await create(plan);
|
||
await page.goto(base + "/app?plan=" + saved.id);
|
||
await page
|
||
.getByRole("button", { name: "Preview report", exact: true })
|
||
.waitFor();
|
||
assert.equal(await page.locator("#sec-machine").count(), 0);
|
||
assert.equal(await page.locator("#sec-roastlog").count(), 0);
|
||
assert.equal(
|
||
await page.locator('#plan-form input[name^="planActual."]').count(),
|
||
0,
|
||
);
|
||
await page
|
||
.getByRole("button", { name: "Preview report", exact: true })
|
||
.click();
|
||
await page
|
||
.frameLocator('iframe[title="Printable roast report"]')
|
||
.getByRole("heading", { name: "Milestone plan" })
|
||
.waitFor();
|
||
await page.getByRole("button", { name: "Close preview" }).click();
|
||
const downloading = page.waitForEvent("download");
|
||
await page.getByRole("button", { name: "Download HTML" }).click();
|
||
const download = await downloading;
|
||
const html = await readFile(await download.path(), "utf8");
|
||
assert.ok(html.includes("440 g"));
|
||
assert.ok(html.includes("761.4"));
|
||
assert.ok(html.includes("Development · 1:15"));
|
||
assert.ok(!html.includes("°C"));
|
||
let pdf = await page.pdf({
|
||
preferCSSPageSize: true,
|
||
printBackground: true,
|
||
});
|
||
assert.equal(
|
||
(await PDFDocument.load(pdf)).getPageCount(),
|
||
2,
|
||
"in-app print must not orphan the footer",
|
||
);
|
||
const standalone = await page.context().browser().newPage();
|
||
for (const [label, content, options] of [
|
||
["downloaded HTML", html, { preferCSSPageSize: true }],
|
||
[
|
||
"110% print scaling",
|
||
renderReportHtml({
|
||
...plan,
|
||
fields: { ...plan.fields, 5.2: "761.4", 4.4: "1:15" },
|
||
}),
|
||
{ preferCSSPageSize: true, scale: 1.1 },
|
||
],
|
||
["A4 paper", html, { format: "A4", preferCSSPageSize: false }],
|
||
]) {
|
||
await page.setContent(content);
|
||
pdf = await page.pdf({ ...options, printBackground: true });
|
||
assert.equal((await PDFDocument.load(pdf)).getPageCount(), 2, label);
|
||
}
|
||
assert.deepEqual(errors, []);
|
||
},
|
||
);
|
||
|
||
test(
|
||
"browser: searchable plan table opens the selected plan and preserves edits across switches",
|
||
browserOptions,
|
||
async (t) => {
|
||
const { page, base, create, errors, db } = await session(t);
|
||
const first = warningPlan();
|
||
const a = await create(first, "Fruit plan");
|
||
const b = await create(
|
||
{
|
||
...blankPlan(),
|
||
fields: {
|
||
...blankPlan().fields,
|
||
0.1: "Espresso coffee",
|
||
0.4: "300",
|
||
1.4: "8:00",
|
||
4.3: "2:00",
|
||
},
|
||
},
|
||
"Espresso plan",
|
||
);
|
||
await page.goto(base + "/app?plan=" + a.id);
|
||
await page.locator('#plan-form input[name="0.4"]').fill("520");
|
||
await page.getByRole("link", { name: "← Your plans", exact: true }).click();
|
||
await page.waitForURL("**/plans*");
|
||
await page.locator("#plans-body .plan-title-link").first().waitFor();
|
||
await page.locator('#plan-filters input[name="q"]').fill("Espresso");
|
||
await page
|
||
.getByRole("link", { name: "Espresso plan", exact: true })
|
||
.click();
|
||
await page.waitForURL("**/app?plan=" + b.id);
|
||
await page.locator('#plan-form input[name="0.1"]').waitFor();
|
||
assert.equal(
|
||
await page.locator('#plan-form input[name="0.1"]').inputValue(),
|
||
"Espresso coffee",
|
||
);
|
||
assert.equal(
|
||
await page.locator("#header-coffee-name").textContent(),
|
||
"Espresso plan",
|
||
);
|
||
await page.getByRole("link", { name: "← Your plans", exact: true }).click();
|
||
await page.waitForURL("**/plans*");
|
||
await page.getByRole("link", { name: "Fruit plan", exact: true }).click();
|
||
await page.waitForURL("**/app?plan=" + a.id);
|
||
await page.locator('#plan-form input[name="0.4"]').waitFor();
|
||
assert.equal(
|
||
await page.locator('#plan-form input[name="0.4"]').inputValue(),
|
||
"520",
|
||
);
|
||
const owner = (
|
||
await db.query("SELECT user_id FROM roast_plans WHERE id=$1", [a.id])
|
||
).rows[0].user_id;
|
||
for (let i = 0; i < 28; i++)
|
||
await db.query(
|
||
"INSERT INTO roast_plans(user_id,plan,name) VALUES($1,$2,$3)",
|
||
[owner, blankPlan(), "Library " + i],
|
||
);
|
||
await page.goto(base + "/plans");
|
||
await page
|
||
.locator("#plans-status")
|
||
.filter({ hasText: "of 30 plans" })
|
||
.waitFor();
|
||
assert.equal(
|
||
await page.locator("#plans-body .plan-title-link").count(),
|
||
25,
|
||
);
|
||
await page.getByRole("button", { name: "Next", exact: true }).click();
|
||
await page.locator("#plans-status").filter({ hasText: "26–30" }).waitFor();
|
||
assert.equal(await page.locator("#plans-body .plan-title-link").count(), 5);
|
||
assert.deepEqual(errors, []);
|
||
},
|
||
);
|
||
|
||
test(
|
||
"browser: Plan vs Actual is editable on finished roasts and survives reload",
|
||
browserOptions,
|
||
async (t) => {
|
||
const { page, base, create, db, errors } = await session(t);
|
||
const plan = await create(warningPlan(), "Fruit plan");
|
||
const owner = (
|
||
await db.query("SELECT user_id FROM roast_plans WHERE id=$1", [plan.id])
|
||
).rows[0].user_id;
|
||
const parsed = {
|
||
roast: { title: "Finished fruit roast" },
|
||
curve: [
|
||
{ t: 0, bt: 200 },
|
||
{ t: 60, bt: 90 },
|
||
{ t: 450, bt: 190 },
|
||
{ t: 525, bt: 200 },
|
||
],
|
||
milestones: [
|
||
{ key: "fc", label: "First crack", timeS: 450, tempC: 190 },
|
||
{ key: "drop", label: "Drop", timeS: 525, tempC: 200 },
|
||
],
|
||
derived: { firstCrackS: 450, dropS: 525, developmentS: 75 },
|
||
};
|
||
const row = (
|
||
await db.query(
|
||
"INSERT INTO actual_roasts(user_id,roast_plan_id,filename,original_content,parsed,evaluation_status) VALUES($1,$2,'fruit.alog','{}',$3,'failed') RETURNING id",
|
||
[owner, plan.id, parsed],
|
||
)
|
||
).rows[0];
|
||
await page.goto(base + "/roasts?roast=" + row.id);
|
||
await page
|
||
.getByRole("heading", { name: "Plan vs Actual", exact: true })
|
||
.waitFor();
|
||
const time = page.getByRole("textbox", {
|
||
name: "First crack actual time",
|
||
exact: true,
|
||
});
|
||
assert.equal(await time.inputValue(), "7:30");
|
||
await time.fill("7:40");
|
||
await page
|
||
.getByRole("textbox", { name: "First crack notes", exact: true })
|
||
.fill("Audible");
|
||
await page.getByRole("button", { name: "Save observations" }).click();
|
||
await page
|
||
.locator("#comparison-status")
|
||
.filter({ hasText: "Observations saved" })
|
||
.waitFor();
|
||
await page.reload();
|
||
await page
|
||
.getByRole("textbox", { name: "First crack actual time", exact: true })
|
||
.waitFor();
|
||
assert.equal(await time.inputValue(), "7:40");
|
||
assert.equal(
|
||
await page
|
||
.getByRole("textbox", { name: "First crack notes", exact: true })
|
||
.inputValue(),
|
||
"Audible",
|
||
);
|
||
assert.deepEqual(errors, []);
|
||
},
|
||
);
|
||
|
||
test(
|
||
"browser: Your Plans recovers a deleted remote draft without losing edits",
|
||
browserOptions,
|
||
async (t) => {
|
||
const { page, base, create, db, errors } = await session(t);
|
||
const saved = await create(warningPlan(), "Recovered fruit");
|
||
await page.goto(base + "/app?plan=" + saved.id);
|
||
await page.locator('#plan-form input[name="0.4"]').waitFor();
|
||
await db.query("DELETE FROM roast_plans WHERE id=$1", [saved.id]);
|
||
await page.locator('#plan-form input[name="0.4"]').fill("530");
|
||
await page.getByRole("link", { name: "← Your plans", exact: true }).click();
|
||
await page.waitForURL("**/plans*");
|
||
await page
|
||
.getByRole("link", { name: "Recovered fruit", exact: true })
|
||
.waitFor();
|
||
const rows = (await db.query("SELECT id,plan FROM roast_plans")).rows;
|
||
assert.equal(rows.length, 1);
|
||
assert.notEqual(rows[0].id, saved.id);
|
||
assert.equal(rows[0].plan.fields["0.4"], "530");
|
||
assert.deepEqual(errors, []);
|
||
},
|
||
);
|
||
|
||
test(
|
||
"browser: Your Plans saves to server when browser draft storage is full",
|
||
browserOptions,
|
||
async (t) => {
|
||
const { page, base, create, db, errors } = await session(t);
|
||
const saved = await create(warningPlan());
|
||
await page.goto(base + "/app?plan=" + saved.id);
|
||
await page.locator('#plan-form input[name="0.4"]').waitFor();
|
||
await page.evaluate(() => {
|
||
const original = Storage.prototype.setItem;
|
||
Storage.prototype.setItem = function (key, value) {
|
||
if (key.startsWith("roastPlannerPlan.v2:"))
|
||
throw new DOMException("Storage full", "QuotaExceededError");
|
||
return original.call(this, key, value);
|
||
};
|
||
});
|
||
await page.locator('#plan-form input[name="0.4"]').fill("540");
|
||
await page.getByRole("link", { name: "← Your plans", exact: true }).click();
|
||
await page.waitForURL("**/plans*");
|
||
assert.equal(
|
||
(await db.query("SELECT plan FROM roast_plans WHERE id=$1", [saved.id]))
|
||
.rows[0].plan.fields["0.4"],
|
||
"540",
|
||
);
|
||
assert.deepEqual(errors, []);
|
||
},
|
||
);
|
||
|
||
test(
|
||
"browser: rejected sync shows the cause and keeps the edited plan open",
|
||
browserOptions,
|
||
async (t) => {
|
||
const { page, base, create, db } = await session(t);
|
||
const saved = await create(warningPlan());
|
||
await page.goto(base + "/app?plan=" + saved.id);
|
||
await page.locator('#plan-form input[name="0.4"]').waitFor();
|
||
await page.route("**/api/plans/" + saved.id, async (route) =>
|
||
route.request().method() === "PUT"
|
||
? route.fulfill({
|
||
status: 403,
|
||
contentType: "application/json",
|
||
body: JSON.stringify({ ok: false, code: "csrf_failed" }),
|
||
})
|
||
: route.continue(),
|
||
);
|
||
await page.locator('#plan-form input[name="0.4"]').fill("550");
|
||
await page.getByRole("link", { name: "← Your plans", exact: true }).click();
|
||
await page.getByText(/Your sign-in session needs refreshing/).waitFor();
|
||
assert.ok(page.url().includes("/app?plan=" + saved.id));
|
||
assert.equal(
|
||
(await db.query("SELECT COUNT(*) AS n FROM roast_plans")).rows[0].n,
|
||
1,
|
||
);
|
||
const draft = await page.evaluate(() =>
|
||
JSON.parse(
|
||
localStorage.getItem(
|
||
Object.keys(localStorage).find(
|
||
(k) =>
|
||
k.startsWith("roastPlannerPlan.v2:") && !k.endsWith(":last-user"),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
assert.equal(draft.plan.fields["0.4"], "550");
|
||
},
|
||
);
|
||
|
||
test(
|
||
"browser: Clone opens an independent plan whose roast level can change",
|
||
browserOptions,
|
||
async (t) => {
|
||
const { page, base, create, db, errors } = await session(t);
|
||
const original = await create(warningPlan(), "Fruit original");
|
||
await page.goto(base + "/plans");
|
||
await page
|
||
.getByRole("button", { name: "Clone Fruit original", exact: true })
|
||
.click();
|
||
await page.waitForURL("**/app?plan=*");
|
||
await page
|
||
.locator("#header-coffee-name")
|
||
.filter({ hasText: "Fruit original (copy)" })
|
||
.waitFor();
|
||
const id = new URL(page.url()).searchParams.get("plan");
|
||
assert.notEqual(id, original.id);
|
||
await page
|
||
.locator("#plan-form label")
|
||
.filter({ has: page.locator('input[name="4.1"][value="medium"]') })
|
||
.click();
|
||
await page.getByRole("link", { name: "← Your plans", exact: true }).click();
|
||
await page.waitForURL("**/plans*");
|
||
await page
|
||
.getByRole("link", { name: "Fruit original (copy)", exact: true })
|
||
.waitFor();
|
||
const rows = (await db.query("SELECT id,plan FROM roast_plans")).rows;
|
||
assert.equal(rows.length, 2);
|
||
assert.equal(
|
||
rows.find((r) => r.id === original.id).plan.fields["4.1"],
|
||
"light",
|
||
);
|
||
assert.equal(rows.find((r) => r.id === id).plan.fields["4.1"], "medium");
|
||
assert.deepEqual(errors, []);
|
||
},
|
||
);
|
||
|
||
test(
|
||
"browser: source URL persists from manual entry and prefill, with undo and cloning",
|
||
browserOptions,
|
||
async (t) => {
|
||
const { page, base, create, db, errors } = await session(t);
|
||
const original = await create(warningPlan(), "Source test");
|
||
await page.goto(base + "/app?plan=" + original.id);
|
||
await page
|
||
.locator("#header-coffee-name")
|
||
.filter({ hasText: "Source test" })
|
||
.waitFor();
|
||
const source = page.locator('#plan-form input[name="sourceUrl"]');
|
||
await source.fill("https://example.com/manual-coffee");
|
||
await page.getByRole("link", { name: "← Your plans", exact: true }).click();
|
||
await page.waitForURL("**/plans*");
|
||
await page.getByRole("link", { name: "Source test", exact: true }).click();
|
||
await page
|
||
.locator("#header-coffee-name")
|
||
.filter({ hasText: "Source test" })
|
||
.waitFor();
|
||
await source.waitFor();
|
||
assert.equal(
|
||
await source.inputValue(),
|
||
"https://example.com/manual-coffee",
|
||
);
|
||
await page.route("**/api/prefill", (route) =>
|
||
route.fulfill({
|
||
status: 200,
|
||
contentType: "application/json",
|
||
body: JSON.stringify({
|
||
ok: true,
|
||
fields: {},
|
||
source: { finalUrl: "https://example.com/new-coffee" },
|
||
warnings: [],
|
||
}),
|
||
}),
|
||
);
|
||
await page.locator("#nav-prefill").click();
|
||
await page.locator("#prefill-url").fill("https://example.com/new-coffee");
|
||
await page.locator("#prefill-go").click();
|
||
await page
|
||
.locator("#prefill-result")
|
||
.filter({ hasText: "Applied 0 fields" })
|
||
.waitFor();
|
||
assert.equal(await source.inputValue(), "https://example.com/new-coffee");
|
||
await page.locator("#prefill-undo").click();
|
||
assert.equal(
|
||
await source.inputValue(),
|
||
"https://example.com/manual-coffee",
|
||
);
|
||
await page.locator("#prefill-go").click();
|
||
await page
|
||
.locator("#prefill-result")
|
||
.filter({ hasText: "Applied 0 fields" })
|
||
.waitFor();
|
||
await page.keyboard.press("Escape");
|
||
await page.getByRole("link", { name: "← Your plans", exact: true }).click();
|
||
await page.waitForURL("**/plans*");
|
||
assert.equal(
|
||
(
|
||
await db.query("SELECT plan FROM roast_plans WHERE id=$1", [
|
||
original.id,
|
||
])
|
||
).rows[0].plan.sourceUrl,
|
||
"https://example.com/new-coffee",
|
||
);
|
||
await page
|
||
.getByRole("button", { name: "Clone Source test", exact: true })
|
||
.click();
|
||
await page.waitForURL("**/app?plan=*");
|
||
await page
|
||
.locator("#header-coffee-name")
|
||
.filter({ hasText: "Source test (copy)" })
|
||
.waitFor();
|
||
await source.waitFor();
|
||
assert.equal(await source.inputValue(), "https://example.com/new-coffee");
|
||
assert.deepEqual(errors, []);
|
||
},
|
||
);
|
||
|
||
test(
|
||
"browser: Bean Bro conversation persists; proposed changes require review and can be undone or cloned",
|
||
browserOptions,
|
||
async (t) => {
|
||
const { page, base, create, db, errors } = await session(t, {
|
||
runPlanChat: async () => ({
|
||
reply: "Try ten extra seconds and compare sweetness.",
|
||
model: "test:coach",
|
||
suggestions: [
|
||
{
|
||
fields: { 4.4: "1:25" },
|
||
reason: "Test development.",
|
||
expected: "More sweetness.",
|
||
evidenceRoastIds: [],
|
||
},
|
||
],
|
||
}),
|
||
});
|
||
const saved = await create(warningPlan(), "Bro experiment");
|
||
await page.goto(base + "/app?plan=" + saved.id);
|
||
await page
|
||
.getByRole("button", { name: "Review this plan", exact: true })
|
||
.click();
|
||
await page
|
||
.getByRole("button", { name: "Apply changes", exact: true })
|
||
.waitFor();
|
||
const override = page.locator('#plan-form [name="4.4"]');
|
||
assert.equal(await override.inputValue(), "");
|
||
await page
|
||
.getByRole("button", { name: "Apply changes", exact: true })
|
||
.click();
|
||
assert.equal(await override.inputValue(), "1:25");
|
||
await page
|
||
.getByRole("button", { name: "Undo changes", exact: true })
|
||
.click();
|
||
assert.equal(await override.inputValue(), "");
|
||
await page.getByRole("link", { name: "← Your plans", exact: true }).click();
|
||
await page.waitForURL("**/plans*");
|
||
await page
|
||
.getByRole("link", { name: "Bro experiment", exact: true })
|
||
.click();
|
||
await page
|
||
.locator("#chat-thread")
|
||
.filter({ hasText: "Try ten extra seconds" })
|
||
.waitFor();
|
||
await page
|
||
.getByRole("button", { name: "Create a variation", exact: true })
|
||
.click();
|
||
await page.waitForURL(
|
||
(url) =>
|
||
url.pathname === "/app" && url.searchParams.get("plan") !== saved.id,
|
||
);
|
||
await page
|
||
.locator("#header-coffee-name")
|
||
.filter({ hasText: "(copy)" })
|
||
.waitFor();
|
||
assert.equal(await override.inputValue(), "1:25");
|
||
const original = (
|
||
await db.query("SELECT plan FROM roast_plans WHERE id=$1", [saved.id])
|
||
).rows[0].plan;
|
||
assert.equal(original.fields["4.4"], "");
|
||
assert.equal(original.assistant.messages.length, 2);
|
||
assert.deepEqual(errors, []);
|
||
},
|
||
);
|
||
test(
|
||
"browser: Source URL has a full row and mobile Bean Bro is a dedicated tab",
|
||
browserOptions,
|
||
async (t) => {
|
||
const { page, base, create, errors } = await session(t);
|
||
const saved = await create(warningPlan());
|
||
await page.goto(base + "/app?plan=" + saved.id);
|
||
await page.locator(".planner-layout").waitFor();
|
||
const source = page.locator('#plan-form [name="sourceUrl"]'),
|
||
coffee = page.locator('#plan-form [name="0.1"]');
|
||
const desktop = await source.boundingBox(),
|
||
coffeeBox = await coffee.boundingBox();
|
||
assert.ok(desktop.width > 400);
|
||
assert.ok(coffeeBox.width > 500);
|
||
await page.setViewportSize({ width: 390, height: 844 });
|
||
await page.waitForTimeout(350);
|
||
await page
|
||
.getByRole("button", { name: "Ask Bean Bro", exact: true })
|
||
.filter({ visible: true })
|
||
.click();
|
||
assert.equal(await page.locator("#panel-chat").isVisible(), true);
|
||
assert.equal(await page.locator(".planner-main").isVisible(), false);
|
||
await page.getByRole("button", { name: "Plan", exact: true }).click();
|
||
await page.waitForTimeout(400);
|
||
assert.equal(await page.locator(".planner-main").isVisible(), true);
|
||
assert.ok((await source.boundingBox()).width > 280);
|
||
assert.equal(
|
||
await page.evaluate(() => document.body.scrollWidth <= innerWidth),
|
||
true,
|
||
);
|
||
assert.deepEqual(errors, []);
|
||
},
|
||
);
|
||
test(
|
||
"browser: roast-day events survive an offline reload",
|
||
browserOptions,
|
||
async (t) => {
|
||
const { page, base, create, errors } = await session(t);
|
||
const saved = await create(warningPlan());
|
||
await page.goto(base + "/app?plan=" + saved.id);
|
||
await page
|
||
.getByRole("button", { name: "Roast day", exact: true })
|
||
.waitFor();
|
||
await page.evaluate(() => navigator.serviceWorker.ready);
|
||
await page.waitForFunction(
|
||
() => navigator.serviceWorker.controller !== null,
|
||
);
|
||
await page.context().setOffline(true);
|
||
await page.getByRole("button", { name: "Roast day", exact: true }).click();
|
||
await page
|
||
.getByRole("button", { name: "Start roast", exact: true })
|
||
.click();
|
||
await page
|
||
.getByRole("spinbutton", {
|
||
name: "Charge actual Fahrenheit",
|
||
exact: true,
|
||
})
|
||
.fill("345");
|
||
await page
|
||
.locator(".roast-event")
|
||
.first()
|
||
.getByRole("button", { name: "Mark event", exact: true })
|
||
.click();
|
||
await page
|
||
.getByRole("button", { name: "Close roast day", exact: true })
|
||
.click();
|
||
await page.reload();
|
||
await page.getByRole("button", { name: "Roast day", exact: true }).click();
|
||
assert.equal(
|
||
await page
|
||
.getByRole("spinbutton", {
|
||
name: "Charge actual Fahrenheit",
|
||
exact: true,
|
||
})
|
||
.inputValue(),
|
||
"345",
|
||
);
|
||
await page.context().setOffline(false);
|
||
assert.deepEqual(errors, []);
|
||
},
|
||
);
|
||
test(
|
||
"browser: coffee history compares measured curves and quick tasting persists",
|
||
browserOptions,
|
||
async (t) => {
|
||
const { page, base, create, db, errors } = await session(t);
|
||
const saved = await create(warningPlan());
|
||
const owner = (
|
||
await db.query("SELECT user_id FROM roast_plans WHERE id=$1", [saved.id])
|
||
).rows[0].user_id;
|
||
let first;
|
||
for (let i = 1; i <= 2; i++) {
|
||
const r = (
|
||
await db.query(
|
||
"INSERT INTO actual_roasts(user_id,roast_plan_id,filename,original_content,parsed,evaluation_status) VALUES($1,$2,$3,'{}',$4,'failed') RETURNING id",
|
||
[
|
||
owner,
|
||
saved.id,
|
||
`test-${i}.alog`,
|
||
{
|
||
roast: { title: `Batch ${i}` },
|
||
curve: [
|
||
{ timeS: 0, tempC: 200 },
|
||
{ timeS: 60, tempC: 90 },
|
||
{ timeS: 500, tempC: 190 },
|
||
],
|
||
derived: { firstCrackS: 450, developmentS: 50, dropS: 500 },
|
||
},
|
||
],
|
||
)
|
||
).rows[0];
|
||
first ??= r.id;
|
||
}
|
||
await page.goto(base + "/roasts?roast=" + first);
|
||
await page
|
||
.getByRole("heading", { name: "Quick taste", exact: true })
|
||
.waitFor();
|
||
await page
|
||
.locator('#after-form [name="tasteBalance"]')
|
||
.selectOption("Balanced");
|
||
await page.locator('#after-form [name="sweetness"]').selectOption("4");
|
||
await page.locator('#after-form [name="favorite"]').selectOption("yes");
|
||
await page
|
||
.getByRole("button", { name: "Save after-roast", exact: true })
|
||
.click();
|
||
await page.waitForFunction(
|
||
() => document.querySelector("#after-save").disabled === false,
|
||
);
|
||
await page.goto(
|
||
base +
|
||
"/coffees?coffee=" +
|
||
encodeURIComponent(
|
||
"coffee:" + warningPlan().fields["0.1"].toLowerCase(),
|
||
),
|
||
);
|
||
await page.getByRole("heading", { name: "Your favorite batch" }).waitFor();
|
||
await page
|
||
.getByRole("checkbox", { name: "Compare Batch 1", exact: true })
|
||
.check();
|
||
await page
|
||
.getByRole("checkbox", { name: "Compare Batch 2", exact: true })
|
||
.check();
|
||
await page
|
||
.getByRole("button", { name: "Compare selected batches" })
|
||
.click();
|
||
await page
|
||
.getByRole("img", {
|
||
name: "Measured roast curves, Fahrenheit versus time",
|
||
})
|
||
.waitFor();
|
||
assert.equal(await page.locator(".comparison-chart polyline").count(), 2);
|
||
assert.deepEqual(errors, []);
|
||
},
|
||
);
|
||
test(
|
||
"browser: finishing manual roast day opens a tasting record",
|
||
browserOptions,
|
||
async (t) => {
|
||
const { page, base, create, errors } = await session(t);
|
||
const p = warningPlan();
|
||
p.roastDay = {
|
||
startedAt: Date.now() - 600000,
|
||
events: {
|
||
charge: { timeS: 0, tempF: "345" },
|
||
fc: { timeS: 500, tempF: "360" },
|
||
drop: { timeS: 580, tempF: "375" },
|
||
},
|
||
};
|
||
const saved = await create(p, "Manual fruit batch");
|
||
await page.goto(base + "/app?plan=" + saved.id);
|
||
await page.getByRole("button", { name: "Roast day", exact: true }).click();
|
||
await page
|
||
.getByRole("button", { name: "Finish & record tasting", exact: true })
|
||
.click();
|
||
await page.waitForURL("**/roasts?roast=*");
|
||
await page
|
||
.getByRole("heading", { name: "Quick taste", exact: true })
|
||
.waitFor();
|
||
await page.getByRole("link", { name: "Download manual record" }).waitFor();
|
||
assert.equal(
|
||
await page.locator("#detail-download-updated").isVisible(),
|
||
false,
|
||
);
|
||
assert.deepEqual(errors, []);
|
||
},
|
||
);
|
||
|
||
test("browser: deleting a plan confirms in-page, supports retry, and preserves roast records", browserOptions, async (t) => {
|
||
const { page, base, create, db, csrfToken, errors } = await session(t);
|
||
const plan = blankPlan();
|
||
const saved = await create(plan, "Burundi — previous light roast plan");
|
||
const keep = await create(plan, "Keep this plan");
|
||
await page.request.put(base + "/api/plans/" + saved.id, {
|
||
headers: { "x-csrf-token": csrfToken }, data: { name: "Burundi — previous light roast plan revised" },
|
||
});
|
||
const owner = (await db.query("SELECT user_id FROM roast_plans WHERE id=$1", [saved.id])).rows[0].user_id;
|
||
const roast = (await db.query("INSERT INTO actual_roasts(user_id,roast_plan_id,filename,original_content,parsed,plan_snapshot) VALUES($1,$2,'saved.alog','{}',$3,$4) RETURNING id", [owner,saved.id,{roast:{}},plan])).rows[0];
|
||
// Browser popup suppression must not turn Delete into a silent no-op.
|
||
await page.addInitScript(() => { window.confirm = () => false; });
|
||
await page.goto(base + "/plans");
|
||
const remove = page.getByRole("button", {name: "Delete Burundi — previous light roast plan revised", exact:true});
|
||
await remove.click();
|
||
const dialog = page.getByRole("dialog", {name:"Delete this plan?"});
|
||
await dialog.waitFor();
|
||
await page.screenshot({path:"/tmp/delete-plan-desktop.png"});
|
||
await page.setViewportSize({width:390,height:844});
|
||
await page.screenshot({path:"/tmp/delete-plan-mobile.png"});
|
||
assert.equal(await page.evaluate(() => document.documentElement.scrollWidth <= innerWidth), true);
|
||
await dialog.getByRole("button",{name:"Cancel",exact:true}).click();
|
||
assert.equal((await page.request.get(base + "/api/plans/" + saved.id)).status(),200);
|
||
await remove.click();
|
||
await page.route("**/api/plans/" + saved.id, async (route) => {
|
||
if (route.request().method() === "DELETE") await route.fulfill({status:503,contentType:"application/json",body:JSON.stringify({error:"Temporary service failure"})});
|
||
else await route.continue();
|
||
});
|
||
await dialog.getByRole("button",{name:"Delete plan",exact:true}).click();
|
||
await page.getByRole("alert").filter({hasText:"Temporary service failure"}).waitFor();
|
||
assert.equal(await dialog.isVisible(),true);
|
||
await page.unroute("**/api/plans/" + saved.id);
|
||
await dialog.getByRole("button",{name:"Delete plan",exact:true}).click();
|
||
await dialog.waitFor({state:"hidden"});
|
||
await page.waitForFunction(() => document.getElementById("plans-status").textContent === "1–1 of 1 plans");
|
||
assert.equal((await page.request.get(base + "/api/plans/" + saved.id)).status(),404);
|
||
assert.equal((await page.request.get(base + "/api/plans/" + keep.id)).status(),200);
|
||
assert.equal((await db.query("SELECT * FROM plan_revisions WHERE plan_id=$1",[saved.id])).rows.length,0);
|
||
const remaining = (await db.query("SELECT * FROM actual_roasts WHERE id=$1",[roast.id])).rows[0];
|
||
assert.equal(remaining.roast_plan_id,null);
|
||
assert.deepEqual(remaining.plan_snapshot,plan);
|
||
assert.deepEqual(errors,[]);
|
||
});
|