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: "browser@example.com", 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: quick tasting persists and removed coffee history redirects to calendar", 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: "Activity calendar", exact:true }).waitFor(); await page.getByRole("heading", { name: "Activity log", exact:true }).waitFor(); assert.equal(new URL(page.url()).pathname,"/home"); await page.goto(base + "/roasts?roast=" + first); await page.locator('#after-form [name="favorite"]').waitFor(); assert.equal(await page.locator('#after-form [name="favorite"]').inputValue(),"yes"); 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,[]); }); test("browser: Bean Bro displays live text before completion and calendar links to activity",browserOptions,async(t)=>{ let release; const gate=new Promise(r=>{release=r;});t.after(()=>release()); const {page,base,create,db,errors}=await session(t,{runPlanChat:async({onText})=>{ onText?.("First crack is shown at 392°F"); await gate; return {reply:"First crack is shown at 392°F as an illustrative reference.",suggestions:[],model:"test:stream"}; }}); const saved=await create(warningPlan()); await page.goto(base+"/app?plan="+saved.id); await page.locator("#chat-input").fill("What is the planned first crack temperature?"); await page.locator("#chat-send").click(); await page.locator("#chat-thread .assistant").filter({hasText:"First crack is shown at 392°F"}).waitFor(); assert.equal(await page.locator("#chat-send").isDisabled(),true); assert.equal(await page.locator(".proposal-card").count(),0); await page.screenshot({path:"/tmp/bean-bro-streaming.png"}); release(); await page.waitForFunction(()=>!document.getElementById("chat-send").disabled); await page.locator("#chat-thread .assistant").filter({hasText:"illustrative reference"}).waitFor(); 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,evaluation_status) VALUES($1,$2,'Burundi.alog','{}',$3,'failed') RETURNING id",[owner,saved.id,{roast:{title:"Burundi · Light & fruity",roastDate:"2026-09-05"},curve:[],derived:{}}])).rows[0]; await db.query("INSERT INTO cupping_sessions(user_id,roast_plan_id,data,created_at) VALUES($1,$2,$3,'2026-09-07')",[owner,saved.id,{}]); const brew=(await db.query("INSERT INTO brews(user_id,method,brewed_at) VALUES($1,'v60','2026-09-08T12:00:00Z') RETURNING id",[owner])).rows[0]; await page.goto(base+"/home?month=2026-09"); await page.getByRole("heading",{name:"Activity log",exact:true}).waitFor(); assert.equal(await page.getByRole("link",{name:"Coffee history",exact:true}).count(),0); assert.equal(await page.getByRole("link",{name:"Today",exact:true}).count(),0); await page.screenshot({path:"/tmp/activity-calendar-desktop.png",fullPage:true,animations:"disabled"}); await page.getByRole("combobox",{name:"Activity type"}).selectOption("roast"); assert.equal(await page.locator(".calendar-agenda .calendar-log-row").count(),1); await page.getByRole("combobox",{name:"Activity type"}).selectOption(""); await page.setViewportSize({width:390,height:844}); await page.reload(); await page.getByRole("heading",{name:"Activity log",exact:true}).waitFor(); await page.screenshot({path:"/tmp/activity-calendar-mobile.png",fullPage:true}); assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),true); await page.locator('.calendar-agenda a[href="/roasts?roast='+roast.id+'"]').click(); await page.getByRole("heading",{name:"Quick taste",exact:true}).waitFor(); await page.goto(base+"/brews?brew="+brew.id); await page.getByRole("heading",{name:"Edit brew",exact:true}).waitFor(); assert.deepEqual(errors,[]); }); test("browser: an interrupted Bean Bro stream keeps partial text, restores the question and allows retry",browserOptions,async(t)=>{ let attempt=0; const {page,base,create,errors}=await session(t,{runPlanChat:async({onText})=>{ onText?.("First crack is shown at 392°F."); if(++attempt===1)throw new Error("Provider disconnected"); return {reply:"First crack is shown at 392°F; this is illustrative.",suggestions:[]}; }}); const saved=await create(warningPlan()); await page.goto(base+"/app?plan="+saved.id); await page.locator("#chat-input").fill("Explain first crack"); await page.locator("#chat-send").click(); await page.locator("#chat-thread").getByText(/Response interrupted/).waitFor(); assert.equal(await page.locator("#chat-input").inputValue(),"Explain first crack"); assert.equal(await page.locator("#chat-send").isDisabled(),false); assert.match(await page.locator("#chat-thread").innerText(),/392°F/); await page.locator("#chat-send").click(); await page.locator("#chat-thread .assistant").filter({hasText:"this is illustrative"}).waitFor(); assert.equal(await page.locator("#chat-thread .user").count(),1); assert.deepEqual(errors,[]); }); test("browser: a per-plan gauge offset updates the graph, persists, clones and prints",browserOptions,async(t)=>{ const {page,browser,base,create,csrfToken,errors}=await session(t); const saved=await create(warningPlan()); await page.goto(base+"/app?plan="+saved.id); await page.getByRole("button",{name:"Preview report",exact:true}).waitFor(); const input=page.getByRole("spinbutton",{name:"Gauge temperature offset (°F)"}); await input.fill("-50"); await page.waitForFunction(()=>document.querySelector("#curve-svg-live").textContent.includes("342°F")); await input.scrollIntoViewIfNeeded(); await page.screenshot({path:"/tmp/gauge-offset-desktop.png",animations:"disabled"}); await page.getByRole("link",{name:"← Your plans",exact:true}).click(); const loaded=(await (await page.request.get(base+"/api/plans/"+saved.id)).json()).plan.plan; assert.equal(loaded.temperatureOffsetF,"-50"); const cloned=await (await page.request.post(base+"/api/plans/"+saved.id+"/clone",{headers:{"x-csrf-token":csrfToken}})).json(); assert.equal(cloned.plan.plan.temperatureOffsetF,"-50"); await page.setViewportSize({width:390,height:844}); await page.goto(base+"/app?plan="+saved.id); await input.waitFor(); assert.equal(await input.inputValue(),"-50"); await input.scrollIntoViewIfNeeded(); await page.screenshot({path:"/tmp/gauge-offset-mobile.png",animations:"disabled"}); assert.equal(await page.evaluate(()=>document.documentElement.scrollWidth<=innerWidth),true); const report=await browser.newPage(); const html=renderReportHtml(loaded); assert.match(html,/Gauge temperature offset/);assert.match(html,/-50°F/);assert.match(html,/342°F/); await report.setContent(html); const {PDFDocument}=await import("pdf-lib"); const pdf=await PDFDocument.load(await report.pdf({format:"Letter",preferCSSPageSize:true})); assert.equal(pdf.getPageCount(),2); await report.close(); assert.deepEqual(errors,[]); });