| 1 | // native-applink.mjs: the browser's half of TestNativeBrowserAppLink. |
| 2 | // |
| 3 | // The web's "Join in the app" (2026-10-02): a person signed in on the web |
| 4 | // hands themselves to the Calls app. The Go test plays the app — it holds |
| 5 | // the verifier and calls callscore exactly as the app does — and drives |
| 6 | // this script one command at a time over stdin, one JSON line back per |
| 7 | // command, so each step's answer can decide the next: |
| 8 | // |
| 9 | // {"cmd":"context","cookie":{name,value}|null,"js":true} a browser |
| 10 | // {"cmd":"goto","path":"/m/x/join"} → {"url","appHref","body"} |
| 11 | // {"cmd":"click-app"} → {"url","app"} (the offer page) |
| 12 | // {"cmd":"guest","name":"Dee"} → {"url"} (guest form) |
| 13 | // {"cmd":"approve","code":"ABCD-1234"} → {"url","text"} (on /native/complete) |
| 14 | // {"cmd":"quit"} |
| 15 | // |
| 16 | // Every command answers {"error": "..."} instead when it cannot do what |
| 17 | // it says, and the test fails with that. |
| 18 | import { chromium } from 'playwright'; |
| 19 | import { createInterface } from 'node:readline'; |
| 20 | |
| 21 | const lines = createInterface({ input: process.stdin }); |
| 22 | const input = lines[Symbol.asyncIterator](); |
| 23 | const origin = JSON.parse((await input.next()).value).origin; |
| 24 | const browser = await chromium.launch({ headless: true }); |
| 25 | let context = null; |
| 26 | let page = null; |
| 27 | const reply = (o) => process.stdout.write(JSON.stringify(o) + '\n'); |
| 28 | |
| 29 | async function appHref() { |
| 30 | const a = page.locator('a[href^="oficina-calls:"], a:has-text("Join in the app")').first(); |
| 31 | return (await a.count()) ? a.getAttribute('href') : ''; |
| 32 | } |
| 33 | |
| 34 | try { |
| 35 | for (;;) { |
| 36 | const next = await input.next(); |
| 37 | if (next.done) break; |
| 38 | const c = JSON.parse(next.value); |
| 39 | try { |
| 40 | switch (c.cmd) { |
| 41 | case 'context': { |
| 42 | if (context) await context.close(); |
| 43 | context = await browser.newContext({ javaScriptEnabled: c.js !== false }); |
| 44 | if (c.cookie) await context.addCookies([{ name: c.cookie.name, value: c.cookie.value, url: origin }]); |
| 45 | page = await context.newPage(); |
| 46 | reply({ ok: true }); |
| 47 | break; |
| 48 | } |
| 49 | case 'goto': { |
| 50 | await page.goto(origin + c.path, { waitUntil: 'domcontentloaded' }); |
| 51 | reply({ url: page.url(), appHref: await appHref(), body: (await page.locator('body').innerText()).slice(0, 2000) }); |
| 52 | break; |
| 53 | } |
| 54 | case 'click-app': { |
| 55 | await Promise.all([ |
| 56 | // Not "load": with script the page opens oficina-calls:// |
| 57 | // straight away, which a browser with no Calls app never |
| 58 | // finishes, and that is the case here. |
| 59 | page.waitForURL('**/native/offer?o=*', { waitUntil: 'commit' }), |
| 60 | page.getByRole('link', { name: 'Join in the app', exact: true }).click(), |
| 61 | ]); |
| 62 | const app = await page.locator('#open-app').getAttribute('href', { timeout: 10000 }); |
| 63 | reply({ url: page.url(), app }); |
| 64 | break; |
| 65 | } |
| 66 | case 'guest': { |
| 67 | await page.locator('input[name="name"]').fill(c.name); |
| 68 | await Promise.all([ |
| 69 | page.waitForURL((u) => !u.pathname.startsWith('/j/')), |
| 70 | page.locator('form[action$="/guest"] button[type="submit"]').click(), |
| 71 | ]); |
| 72 | reply({ url: page.url() }); |
| 73 | break; |
| 74 | } |
| 75 | case 'approve': { |
| 76 | // The waiting page turns into the confirmation by itself — by |
| 77 | // script, or by its own refresh with none — once the app has |
| 78 | // asked. Nothing here navigates it. |
| 79 | await page.waitForURL('**/native/authorize?id=*', { timeout: 20000, waitUntil: 'domcontentloaded' }); |
| 80 | const text = await page.locator('body').innerText(); |
| 81 | if (!text.includes(c.code)) throw new Error('the confirmation does not show the app\'s code ' + c.code + ': ' + text.slice(0, 400)); |
| 82 | const button = page.getByRole('button', { name: /^(Sign in to Calls app|Continue as guest)$/ }); |
| 83 | await Promise.all([page.waitForURL('**/native/complete'), button.click()]); |
| 84 | reply({ url: page.url(), text: (await page.locator('body').innerText()).slice(0, 400) }); |
| 85 | break; |
| 86 | } |
| 87 | case 'quit': |
| 88 | reply({ ok: true }); |
| 89 | throw new Error('quit'); |
| 90 | default: |
| 91 | reply({ error: 'unknown command ' + c.cmd }); |
| 92 | } |
| 93 | } catch (e) { |
| 94 | if (e.message === 'quit') throw e; |
| 95 | reply({ error: String(e && e.message || e) }); |
| 96 | } |
| 97 | } |
| 98 | } catch (e) { |
| 99 | if (e.message !== 'quit') throw e; |
| 100 | } finally { |
| 101 | await browser.close(); |
| 102 | lines.close(); |
| 103 | process.stdin.destroy(); |
| 104 | } |
| 105 | |