| 1 | // quickadd.mjs — the quick-add box in a real browser, both ways. |
| 2 | // |
| 3 | // Driven by TestQuickAddInARealBrowser, which starts the app, signs in, |
| 4 | // and passes this script the origin and a session cookie. Playwright |
| 5 | // rather than chromedp: chromedp does not drive in this environment at |
| 6 | // all (it starts Chromium and parks at about:blank until the Go test |
| 7 | // times out), and a gate that cannot run is not a gate. |
| 8 | // |
| 9 | // Five passes, and the scriptless ones matter most: |
| 10 | // |
| 11 | // 1. WITH script — type, and the pane fills in from the server. |
| 12 | // 2. WITHOUT script — the same box still opens, still submits, and |
| 13 | // still creates the event. |
| 14 | // 3. WITH script — Enter adds it, Shift+Enter does not. |
| 15 | // 4. WITHOUT script — the Enter hint is absent, because the shortcut |
| 16 | // does not exist there and a page must not promise one. |
| 17 | // 5. WITH script — a date range reads as a run of days, and the pane |
| 18 | // names the last one. |
| 19 | // |
| 20 | // The scriptless passes are the only way to prove the floor is real. |
| 21 | // Every other test in this repo asks Go what Go produced; these ask a |
| 22 | // browser with JavaScript switched off whether the feature still works. |
| 23 | import { chromium } from 'playwright'; |
| 24 | |
| 25 | const [origin, cookieName, cookieValue] = process.argv.slice(2); |
| 26 | if (!origin || !cookieName || !cookieValue) { |
| 27 | console.error('usage: quickadd.mjs <origin> <cookieName> <cookieValue>'); |
| 28 | process.exit(2); |
| 29 | } |
| 30 | |
| 31 | const failures = []; |
| 32 | function check(ok, what) { |
| 33 | if (ok) return; |
| 34 | failures.push(what); |
| 35 | console.error('FAIL: ' + what); |
| 36 | } |
| 37 | |
| 38 | const SENTENCE = 'Board meeting with doc@tito.io tomorrow at 3pm in McCloskeys'; |
| 39 | |
| 40 | const browser = await chromium.launch(); |
| 41 | |
| 42 | async function contextWith(javaScriptEnabled) { |
| 43 | const ctx = await browser.newContext({ javaScriptEnabled }); |
| 44 | await ctx.addCookies([ |
| 45 | { name: cookieName, value: cookieValue, url: origin, httpOnly: true }, |
| 46 | ]); |
| 47 | return ctx; |
| 48 | } |
| 49 | |
| 50 | // ── 1. With script: the pane fills in as you type ──────────────────── |
| 51 | { |
| 52 | const ctx = await contextWith(true); |
| 53 | const page = await ctx.newPage(); |
| 54 | |
| 55 | const errors = []; |
| 56 | page.on('console', (m) => { if (m.type() === 'error') errors.push(m.text()); }); |
| 57 | page.on('pageerror', (e) => errors.push(String(e))); |
| 58 | |
| 59 | await page.goto(origin + '/today', { waitUntil: 'networkidle' }); |
| 60 | |
| 61 | const box = page.locator('[cal-quick]'); |
| 62 | check(await box.count() === 1, 'the quick add box is on the day page'); |
| 63 | |
| 64 | // Closed, the form must not be reachable — a popover that is merely |
| 65 | // transparent is still in the tab order. |
| 66 | check(!(await page.locator('[cal-quick-text]').isVisible()), |
| 67 | 'the textarea is hidden until the box is opened'); |
| 68 | |
| 69 | await page.locator('[cal-quick] > summary').click(); |
| 70 | check(await page.locator('[cal-quick-text]').isVisible(), |
| 71 | 'clicking the box opens it'); |
| 72 | |
| 73 | // The crossing from computed value to PAINTED output: the popover has |
| 74 | // to have real size and sit inside the viewport. A panel positioned |
| 75 | // off the side of the page passes every string assertion in the Go |
| 76 | // suite and is invisible to a person. |
| 77 | const rect = await page.locator('[cal-quick-form]').boundingBox(); |
| 78 | check(rect !== null && rect.width > 200 && rect.height > 80, |
| 79 | 'the popover has real painted size (got ' + JSON.stringify(rect) + ')'); |
| 80 | const vw = page.viewportSize().width; |
| 81 | check(rect !== null && rect.x >= 0 && rect.x + rect.width <= vw + 1, |
| 82 | 'the popover sits inside the viewport (got ' + JSON.stringify(rect) + ')'); |
| 83 | |
| 84 | await page.locator('[cal-quick-text]').fill(SENTENCE); |
| 85 | |
| 86 | // The pane is filled by the SERVER. Waiting for its text is waiting |
| 87 | // for the round trip, which is the thing under test. |
| 88 | await page.locator('[cal-quick-pane]').getByText('McCloskeys').waitFor({ timeout: 5000 }); |
| 89 | |
| 90 | const pane = await page.locator('[cal-quick-pane]').innerText(); |
| 91 | check(pane.includes('Board meeting'), 'the pane shows the title it parsed'); |
| 92 | check(pane.includes('McCloskeys'), 'the pane shows the place'); |
| 93 | check(pane.includes('doc@tito.io'), 'the pane shows the attendee'); |
| 94 | check(pane.includes('15:00'), 'the pane resolved 3pm to 15:00, not 03:00'); |
| 95 | check(/not emailed/i.test(pane), 'the pane says nobody is emailed'); |
| 96 | |
| 97 | check(errors.length === 0, 'no console errors (' + errors.join('; ') + ')'); |
| 98 | await ctx.close(); |
| 99 | } |
| 100 | |
| 101 | // ── 2. Without script: the same box still works ────────────────────── |
| 102 | { |
| 103 | const ctx = await contextWith(false); |
| 104 | const page = await ctx.newPage(); |
| 105 | await page.goto(origin + '/today'); |
| 106 | |
| 107 | // <details> is native, so it opens with no JavaScript at all. |
| 108 | await page.locator('[cal-quick] > summary').click(); |
| 109 | check(await page.locator('[cal-quick-text]').isVisible(), |
| 110 | 'the box opens with JavaScript disabled'); |
| 111 | |
| 112 | await page.locator('[cal-quick-text]').fill('Scriptless lunch tomorrow at 1pm in Fumbally'); |
| 113 | await Promise.all([ |
| 114 | page.waitForNavigation(), |
| 115 | page.locator('[cal-quick-form] button[type=submit]').click(), |
| 116 | ]); |
| 117 | |
| 118 | check(/\/events\/\d+$/.test(page.url()), |
| 119 | 'submitting with no script lands on the new event (got ' + page.url() + ')'); |
| 120 | const body = await page.locator('body').innerText(); |
| 121 | check(body.includes('Scriptless lunch'), 'the scriptless event kept its title'); |
| 122 | check(body.includes('Fumbally'), 'the scriptless event kept its place'); |
| 123 | await ctx.close(); |
| 124 | } |
| 125 | |
| 126 | // ── 3. Enter adds it; Shift+Enter does not ────────────────────────── |
| 127 | { |
| 128 | const ctx = await contextWith(true); |
| 129 | const page = await ctx.newPage(); |
| 130 | await page.goto(origin + '/today', { waitUntil: 'networkidle' }); |
| 131 | await page.locator('[cal-quick] > summary').click(); |
| 132 | |
| 133 | const box = page.locator('[cal-quick-text]'); |
| 134 | |
| 135 | // Shift+Enter is a newline, so it must NOT create anything. Asserted |
| 136 | // as "did not land on an event" rather than "still on /today": /today |
| 137 | // redirects to /day/<date>, so the page was never on /today to begin |
| 138 | // with and the tighter-looking assertion was just wrong. |
| 139 | // |
| 140 | // Checked BEFORE the submitting case — once Enter navigates there is no |
| 141 | // page left to test this on. |
| 142 | await box.fill('Draft lunch'); |
| 143 | await box.press('Shift+Enter'); |
| 144 | await page.waitForTimeout(300); |
| 145 | check(!/\/events\/\d+$/.test(page.url()), |
| 146 | 'Shift+Enter created nothing (got ' + page.url() + ')'); |
| 147 | check((await box.inputValue()).includes('\n'), |
| 148 | 'Shift+Enter inserts a newline rather than submitting'); |
| 149 | |
| 150 | // The hint only appears when this file is running, because without it |
| 151 | // Enter does nothing. |
| 152 | check(await page.locator('[cal-quick-key]').isVisible(), |
| 153 | 'the Enter hint is shown when script is on'); |
| 154 | |
| 155 | await box.fill('Keyboard standup tomorrow at 8am in the Loft'); |
| 156 | await Promise.all([ |
| 157 | page.waitForNavigation(), |
| 158 | box.press('Enter'), |
| 159 | ]); |
| 160 | |
| 161 | check(/\/events\/\d+$/.test(page.url()), |
| 162 | 'Enter creates the event (got ' + page.url() + ')'); |
| 163 | const body = await page.locator('body').innerText(); |
| 164 | check(body.includes('Keyboard standup'), 'the event Enter created kept its title'); |
| 165 | check(body.includes('Loft'), 'the event Enter created kept its place'); |
| 166 | await ctx.close(); |
| 167 | } |
| 168 | |
| 169 | // ── 4. The Enter hint is absent with no script ────────────────────── |
| 170 | { |
| 171 | const ctx = await contextWith(false); |
| 172 | const page = await ctx.newPage(); |
| 173 | await page.goto(origin + '/today'); |
| 174 | await page.locator('[cal-quick] > summary').click(); |
| 175 | check(!(await page.locator('[cal-quick-key]').isVisible()), |
| 176 | 'the Enter hint stays hidden with JavaScript off — the shortcut does not work there'); |
| 177 | await ctx.close(); |
| 178 | } |
| 179 | |
| 180 | // ── 5. A date range is a run of days ──────────────────────────────── |
| 181 | { |
| 182 | const ctx = await contextWith(true); |
| 183 | const page = await ctx.newPage(); |
| 184 | await page.goto(origin + '/today', { waitUntil: 'networkidle' }); |
| 185 | await page.locator('[cal-quick] > summary').click(); |
| 186 | |
| 187 | await page.locator('[cal-quick-text]').fill('london tues-thurs'); |
| 188 | await page.waitForFunction( |
| 189 | () => /Tuesday/.test(document.querySelector('[cal-quick-pane]')?.innerText || ''), |
| 190 | null, { timeout: 5000 }, |
| 191 | ); |
| 192 | |
| 193 | const pane = await page.locator('[cal-quick-pane]').innerText(); |
| 194 | check(/Tuesday/.test(pane) && /Thursday/.test(pane), |
| 195 | 'the pane names both ends of the run:\n' + pane); |
| 196 | check(/all day/i.test(pane), |
| 197 | 'the pane says a run with no time is all day:\n' + pane); |
| 198 | // The stranded word: the whole reason this pass exists. |
| 199 | check(!/thurs\b/i.test(pane.split('\n')[0] || ''), |
| 200 | 'the end of the range was left in the title:\n' + pane); |
| 201 | await ctx.close(); |
| 202 | } |
| 203 | |
| 204 | await browser.close(); |
| 205 | |
| 206 | if (failures.length) { |
| 207 | console.error('\n' + failures.length + ' browser check(s) failed'); |
| 208 | process.exit(1); |
| 209 | } |
| 210 | console.log('quick add: all browser checks passed'); |
| 211 | |