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) { const { chromium } = await import("playwright"); const { app, db } = await setup(); 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", ); 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,[]); });