| 1 | // booking.mjs — a stranger books, in a real browser, with NO SCRIPT. |
| 2 | // |
| 3 | // Driven by TestBookingInARealBrowser, which starts the app, publishes a |
| 4 | // page, and calls this three times: |
| 5 | // |
| 6 | // hold <origin> <publicPath> JS off, no cookie: pick a day, |
| 7 | // a slot, type a name and an |
| 8 | // address, read "Check your mail". |
| 9 | // confirm <origin> <manageLink> JS off, no cookie: open the |
| 10 | // mailed link, see ONE button, |
| 11 | // press it, see Reschedule. |
| 12 | // pane <origin> <cookieName> <cookieValue> |
| 13 | // JS on, signed in: type an |
| 14 | // availability sentence and watch |
| 15 | // the pane fill in from the server. |
| 16 | // |
| 17 | // Go holds the mail recorder, which is why the hold and the confirm are |
| 18 | // separate invocations: only the Go side can read the link out of the |
| 19 | // mail that the hold sent. |
| 20 | import { chromium } from 'playwright'; |
| 21 | |
| 22 | const [mode, origin, a, b] = process.argv.slice(2); |
| 23 | if (!mode || !origin) { |
| 24 | console.error('usage: booking.mjs hold|confirm|pane <origin> ...'); |
| 25 | process.exit(2); |
| 26 | } |
| 27 | |
| 28 | const failures = []; |
| 29 | function check(ok, what) { |
| 30 | if (ok) return; |
| 31 | failures.push(what); |
| 32 | console.error('FAIL: ' + what); |
| 33 | } |
| 34 | |
| 35 | const browser = await chromium.launch(); |
| 36 | |
| 37 | async function contextWith(javaScriptEnabled, cookie) { |
| 38 | const ctx = await browser.newContext({ javaScriptEnabled }); |
| 39 | if (cookie) { |
| 40 | await ctx.addCookies([{ name: cookie.name, value: cookie.value, url: origin, httpOnly: true }]); |
| 41 | } |
| 42 | return ctx; |
| 43 | } |
| 44 | |
| 45 | if (mode === 'hold') { |
| 46 | const ctx = await contextWith(false); |
| 47 | const page = await ctx.newPage(); |
| 48 | await page.goto(origin + a); |
| 49 | check(await page.locator('[cal-book-days] a').count() > 0, 'the page lists days with free slots'); |
| 50 | |
| 51 | await page.locator('[cal-book-days] a').first().click(); |
| 52 | const slots = page.locator('[cal-book-slot]'); |
| 53 | check(await slots.count() > 0, 'the day page lists slots'); |
| 54 | |
| 55 | // The slot's painted size: a button the size of nothing is a button |
| 56 | // nobody can press. |
| 57 | const rect = await slots.first().boundingBox(); |
| 58 | check(rect !== null && rect.width > 30 && rect.height > 20, |
| 59 | 'a slot button has real painted size (got ' + JSON.stringify(rect) + ')'); |
| 60 | |
| 61 | await slots.first().click(); |
| 62 | check(await page.locator('[cal-book-details]').isVisible(), 'picking a slot shows the details form'); |
| 63 | |
| 64 | await page.fill('#name', 'Doc'); |
| 65 | await page.fill('#address', 'doc@tito.io'); |
| 66 | await page.fill('#note', 'From a real browser, with no script.'); |
| 67 | await Promise.all([ |
| 68 | page.waitForNavigation(), |
| 69 | page.locator('[cal-book-details] button[type=submit]').click(), |
| 70 | ]); |
| 71 | const body = await page.locator('body').innerText(); |
| 72 | check(body.includes('Check your mail'), 'the hold lands on "Check your mail"'); |
| 73 | check(body.includes('doc@tito.io'), 'the page echoes the address the mail went to'); |
| 74 | await ctx.close(); |
| 75 | } |
| 76 | |
| 77 | if (mode === 'confirm') { |
| 78 | const ctx = await contextWith(false); |
| 79 | const page = await ctx.newPage(); |
| 80 | await page.goto(origin + a); |
| 81 | // The page's own forms, not the shell's theme switcher: exactly one |
| 82 | // POST to /confirm, and no other way to change anything. |
| 83 | const buttons = page.locator('form[action$="/confirm"] button[type=submit]'); |
| 84 | check(await buttons.count() === 1, 'the mailed link shows exactly one confirm button'); |
| 85 | check((await buttons.first().innerText()).includes('Confirm'), 'and it says Confirm'); |
| 86 | check(await page.locator('form[action*="/book/m/"]').count() === 1, |
| 87 | 'the hold page has no other booking form — nothing else can change on a GET'); |
| 88 | |
| 89 | await Promise.all([page.waitForNavigation(), buttons.first().click()]); |
| 90 | const body = await page.locator('body').innerText(); |
| 91 | check(body.includes('Reschedule'), 'pressing Confirm lands on the manage page'); |
| 92 | check(body.includes('Add to calendar'), 'the manage page offers the ICS'); |
| 93 | await ctx.close(); |
| 94 | } |
| 95 | |
| 96 | if (mode === 'pane') { |
| 97 | const ctx = await contextWith(true, { name: a, value: b }); |
| 98 | const page = await ctx.newPage(); |
| 99 | const errors = []; |
| 100 | page.on('pageerror', (e) => errors.push(String(e))); |
| 101 | await page.goto(origin + '/settings/booking/new', { waitUntil: 'networkidle' }); |
| 102 | await page.fill('[cal-book-text]', 'tues 12-2, 5-7'); |
| 103 | await page.locator('[cal-book-pane]').getByText('Tuesday 12:00').waitFor({ timeout: 5000 }); |
| 104 | const pane = await page.locator('[cal-book-pane]').innerText(); |
| 105 | check(pane.includes('Tuesday 12:00–14:00, 17:00–19:00'), 'the pane describes both windows (got ' + pane + ')'); |
| 106 | check(errors.length === 0, 'no page errors (' + errors.join('; ') + ')'); |
| 107 | await ctx.close(); |
| 108 | } |
| 109 | |
| 110 | await browser.close(); |
| 111 | if (failures.length > 0) { |
| 112 | console.error(failures.length + ' check(s) failed'); |
| 113 | process.exit(1); |
| 114 | } |
| 115 | console.log('booking ' + mode + ': all checks passed'); |
| 116 | |