oxedyne/daimond/dev/shot_betatier.mjs
9.7 KiB, 1 run
created by r2519314175:145, which is this file's identity for as long as the history lasts, whatever it is later renamed to
download · who wrote it · its history
| 1 | // shot_betatier.mjs — the Beta passcodes panel, driven as an operator, with the |
| 2 | // tier control used both ways. |
| 3 | // |
| 4 | // node dev/shot_betatier.mjs |
| 5 | // |
| 6 | // The panel is the only place a Pro code can be asked for, and the control that |
| 7 | // asks is a `<select>` inside `.admin-rel-add`, where the console's own CSS sets |
| 8 | // `appearance: none` on every input. A pulldown that renders as a blank box, or |
| 9 | // a tick nobody can see, is a control that exists in the source and not on the |
| 10 | // screen -- so this drives it and photographs what it did. |
| 11 | // |
| 12 | // What it reports: |
| 13 | // |
| 14 | // * the mint form holds a tier control, and what its options say; |
| 15 | // * minting with it left alone produces a FREE row, drawn as one; |
| 16 | // * minting with it set to Pro produces a Pro row, drawn as one; |
| 17 | // * both rows come back from the gateway with the tier the panel asked for. |
| 18 | // |
| 19 | // A gateway and a dev server of its own, on ports of their own, so this can run |
| 20 | // while other lanes hold :9002 and :8777. The store is empty, because the rows |
| 21 | // it counts would otherwise be somebody else's afternoon. |
| 22 | import fs from 'node:fs'; |
| 23 | import os from 'node:os'; |
| 24 | import path from 'node:path'; |
| 25 | import { spawn } from 'node:child_process'; |
| 26 | import { fileURLToPath, pathToFileURL } from 'node:url'; |
| 27 | import { requireFreshGateway, procLog, GWDIR, GWBIN, openBeta } from './gwbin.mjs'; |
| 28 | import { signInFresh } from './session.mjs'; |
| 29 | |
| 30 | const HERE = path.dirname(fileURLToPath(import.meta.url)); |
| 31 | const ROOT = path.join(HERE, '..'); |
| 32 | // 9418/8793 rather than 9412/8791: verify_redeem.mjs owns :9412 and |
| 33 | // verify_ptyedge.mjs owns :9422/:8797, so both the original pair and the obvious |
| 34 | // replacement collide inside one suite run. These two are unused across dev/. |
| 35 | const GW_PORT = Number(process.env.DAIMOND_GW_PORT || 9418); |
| 36 | const APP_PORT = Number(process.env.DAIMOND_PORT || 8793); |
| 37 | const GW = `http://127.0.0.1:${GW_PORT}`; |
| 38 | const APP = `http://localhost:${APP_PORT}`; |
| 39 | const SCRATCH = process.env.DAIMOND_SCRATCH || path.join(os.homedir(), '.cache/daimond'); |
| 40 | const LANE = path.join(SCRATCH, 'laneA'); |
| 41 | const WORK = path.join(LANE, 'shot_betatier-gw'); |
| 42 | const SHOTS = path.join(LANE, 'shots'); |
| 43 | const GW_LOG = procLog('shot_betatier'); |
| 44 | const SRV_LOG = procLog('shot_betatier', 'serve'); |
| 45 | const PW = process.env.DAIMOND_PW |
| 46 | || path.join(os.homedir(), '.red-pw/node_modules/playwright-core/index.mjs'); |
| 47 | const CHROME = process.env.DAIMOND_CHROME |
| 48 | || `${process.env.HOME}/.cache/ms-playwright/chromium-1229/chrome-linux64/chrome`; |
| 49 | |
| 50 | const sleep = ms => new Promise(r => setTimeout(r, ms)); |
| 51 | const procs = []; |
| 52 | function launch(cmd, args, opts) { const p = spawn(cmd, args, opts); procs.push(p); return p; } |
| 53 | function cleanup() { for (const p of procs) { try { p.kill('SIGKILL'); } catch (e) {} } } |
| 54 | async function waitFor(fn, ms = 30000, gap = 250) { |
| 55 | const t0 = Date.now(); |
| 56 | for (;;) { |
| 57 | try { if (await fn()) return true; } catch (e) {} |
| 58 | if (Date.now() - t0 > ms) return false; |
| 59 | await sleep(gap); |
| 60 | } |
| 61 | } |
| 62 | |
| 63 | const ok = [], bad = []; |
| 64 | const check = (name, pass, detail) => { |
| 65 | (pass ? ok : bad).push(name); |
| 66 | console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : '')); |
| 67 | }; |
| 68 | |
| 69 | requireFreshGateway(); |
| 70 | |
| 71 | /// The working directory: the deployed config with its port moved, registration |
| 72 | /// open, and the licence key symlinked in -- a gifted Pro licence has to be |
| 73 | /// signed with the same key a bought one is. |
| 74 | function buildWorkDir() { |
| 75 | fs.rmSync(WORK, { recursive: true, force: true }); |
| 76 | fs.mkdirSync(path.join(WORK, 'keys'), { recursive: true }); |
| 77 | fs.mkdirSync(SHOTS, { recursive: true }); |
| 78 | for (const k of ['licence', 'stripe', 'openrouter']) { |
| 79 | const from = path.join(GWDIR, 'keys', k); |
| 80 | if (fs.existsSync(from)) fs.symlinkSync(from, path.join(WORK, 'keys', k)); |
| 81 | } |
| 82 | const cfg = fs.readFileSync(path.join(GWDIR, 'app.jdat'), 'utf8') |
| 83 | .replace(/"listen_port":\s*\(u16\|\d+\)/, `"listen_port": (u16|${GW_PORT})`); |
| 84 | if (!cfg.includes(`(u16|${GW_PORT})`)) { |
| 85 | console.log(' FAIL could not set the listen port in the copied app.jdat.'); |
| 86 | process.exit(1); |
| 87 | } |
| 88 | fs.writeFileSync(path.join(WORK, 'app.jdat'), openBeta(cfg, 'shot_betatier')); |
| 89 | return WORK; |
| 90 | } |
| 91 | |
| 92 | let gw = null; |
| 93 | async function startGateway(cwd, owner) { |
| 94 | if (gw) { try { gw.kill('SIGKILL'); } catch (e) {} await sleep(1200); } |
| 95 | gw = launch(GWBIN, [], { |
| 96 | cwd, |
| 97 | env: { ...process.env, APP_MODE: 'sandbox', |
| 98 | ...(owner ? { DAIMOND_OWNER_ACCOUNTS: owner } : {}) }, |
| 99 | stdio: GW_LOG.stdio, |
| 100 | }); |
| 101 | return await waitFor(async () => (await fetch(`${GW}/api/health`)).ok, 25000); |
| 102 | } |
| 103 | |
| 104 | (async () => { |
| 105 | const cwd = buildWorkDir(); |
| 106 | if (!await startGateway(cwd, null)) { |
| 107 | console.log(' FAIL the gateway did not start'); GW_LOG.report(); cleanup(); process.exit(1); |
| 108 | } |
| 109 | launch('node', ['dev/serve.mjs'], { |
| 110 | cwd: ROOT, |
| 111 | env: { ...process.env, DAIMOND_PORT: String(APP_PORT), DAIMOND_GW_PORT: String(GW_PORT) }, |
| 112 | stdio: SRV_LOG.stdio, |
| 113 | }); |
| 114 | if (!await waitFor(async () => (await fetch(`${APP}/console/`)).ok, 20000)) { |
| 115 | console.log(' FAIL the dev server did not serve'); SRV_LOG.report(); cleanup(); process.exit(1); |
| 116 | } |
| 117 | |
| 118 | const { chromium } = await import(pathToFileURL(PW).href); |
| 119 | // DISPLAY is dropped for the reason dev/harness.mjs drops it: a forwarded X |
| 120 | // display that nobody answers leaves headless Chrome producing no frames, so |
| 121 | // every rAF-based wait expires over a page that was ready long before. |
| 122 | const env = { ...process.env }; |
| 123 | delete env.DISPLAY; |
| 124 | const browser = await chromium.launch({ |
| 125 | executablePath: CHROME, headless: true, args: ['--no-sandbox'], env }); |
| 126 | try { |
| 127 | const ctx = await browser.newContext({ viewport: { width: 1400, height: 1200 } }); |
| 128 | const page = await ctx.newPage(); |
| 129 | const errs = []; |
| 130 | page.on('pageerror', e => errs.push(e.message)); |
| 131 | const acct = await signInFresh(page, APP); |
| 132 | // The owner is pinned in configuration, which is what makes the mint form |
| 133 | // appear at all: `buildBetaMint` draws nothing for anybody else. |
| 134 | if (!await startGateway(cwd, acct)) { |
| 135 | console.log(' FAIL the gateway did not restart with the owner pinned'); |
| 136 | GW_LOG.report(); cleanup(); process.exit(1); |
| 137 | } |
| 138 | |
| 139 | await page.goto(`${APP}/console/#beta`, { waitUntil: 'domcontentloaded' }); |
| 140 | await page.waitForSelector('#admin-app:not([hidden])', { timeout: 20000 }); |
| 141 | const formed = await waitFor(async () => await page.evaluate( |
| 142 | () => !!document.getElementById('admin-beta-tier')), 30000); |
| 143 | check('the mint form holds a tier control', formed); |
| 144 | if (!formed) { |
| 145 | await page.screenshot({ path: path.join(SHOTS, 'betatier-nopanel.png') }); |
| 146 | throw new Error('no tier control was drawn'); |
| 147 | } |
| 148 | |
| 149 | const opts = await page.evaluate(() => { |
| 150 | const s = document.getElementById('admin-beta-tier'); |
| 151 | return { value: s.value, texts: Array.from(s.options).map(o => o.value + ': ' + o.textContent) }; |
| 152 | }); |
| 153 | check('it opens on the free tier', opts.value === 'free', JSON.stringify(opts.texts)); |
| 154 | |
| 155 | /// Mint through the panel itself: type the label, set the tier, press Mint, |
| 156 | /// and wait for the status line to settle. |
| 157 | async function mintThroughPanel(label, tier) { |
| 158 | await page.fill('#admin-beta-label', label); |
| 159 | if (tier) await page.selectOption('#admin-beta-tier', tier); |
| 160 | await page.click('#admin-beta-mint'); |
| 161 | await waitFor(async () => await page.evaluate(() => { |
| 162 | const s = (document.getElementById('admin-beta-status') || {}).textContent || ''; |
| 163 | return s !== '' && s !== 'Minting…'; |
| 164 | }), 25000); |
| 165 | return await page.evaluate(() => |
| 166 | (document.getElementById('admin-beta-status') || {}).textContent || ''); |
| 167 | } |
| 168 | |
| 169 | const freeSaid = await mintThroughPanel('Sam, free cohort', null); |
| 170 | check('minting with the control untouched says it was free', |
| 171 | /free passcode/.test(freeSaid), freeSaid); |
| 172 | const proSaid = await mintThroughPanel('Sam, mail cohort', 'pro'); |
| 173 | check('minting with it set to Pro says so', /Pro passcode/.test(proSaid), proSaid); |
| 174 | |
| 175 | // What the rows now SAY, read off the drawn panel rather than off the |
| 176 | // reply: the pill is the only thing that tells an operator which of two |
| 177 | // codes in front of them gifts a licence. |
| 178 | const rows = await page.evaluate(() => Array.from( |
| 179 | document.querySelectorAll('#admin-beta-list .admin-rel-row')).map(r => ({ |
| 180 | label: (r.querySelector('.admin-rel-blurb') || {}).textContent || '', |
| 181 | pills: Array.from(r.querySelectorAll('.admin-pill')).map(p => p.textContent), |
| 182 | }))); |
| 183 | const freeRow = rows.find(r => r.label === 'Sam, free cohort'); |
| 184 | const proRow = rows.find(r => r.label === 'Sam, mail cohort'); |
| 185 | check('the free row is drawn as free', |
| 186 | !!freeRow && freeRow.pills.includes('free'), JSON.stringify(freeRow)); |
| 187 | check('the Pro row is drawn as Pro', |
| 188 | !!proRow && proRow.pills.includes('Pro'), JSON.stringify(proRow)); |
| 189 | |
| 190 | // And the gateway agrees, which is what says the control reached it rather |
| 191 | // than only the panel's own sentence. |
| 192 | const seen = await page.evaluate(async () => { |
| 193 | const r = await fetch('/api/admin?view=passcodes', { |
| 194 | credentials: 'same-origin', headers: { 'x-daimond-api': '1' } }); |
| 195 | const j = await r.json(); |
| 196 | return (j.passcodes || []).map(p => p.label + ': ' + p.pro); |
| 197 | }); |
| 198 | check('the gateway holds the tier the panel asked for', |
| 199 | seen.includes('Sam, free cohort: false') && seen.includes('Sam, mail cohort: true'), |
| 200 | JSON.stringify(seen)); |
| 201 | |
| 202 | await page.locator('#admin-beta-card').screenshot( |
| 203 | { path: path.join(SHOTS, 'betatier-panel.png') }).catch(() => {}); |
| 204 | await page.locator('#admin-beta-add').screenshot( |
| 205 | { path: path.join(SHOTS, 'betatier-form.png') }).catch(() => {}); |
| 206 | if (errs.length) { |
| 207 | check('the page threw nothing', false, errs.join(' · ')); |
| 208 | } else { |
| 209 | check('the page threw nothing', true); |
| 210 | } |
| 211 | console.log('\n shots under ' + SHOTS); |
| 212 | } catch (e) { |
| 213 | console.error(e); |
| 214 | GW_LOG.report(); |
| 215 | bad.push('the run finished'); |
| 216 | } finally { |
| 217 | await browser.close(); |
| 218 | cleanup(); |
| 219 | } |
| 220 | console.log(`\n${ok.length} passed, ${bad.length} failed`); |
| 221 | process.exit(bad.length ? 1 : 0); |
| 222 | })().catch(e => { console.error(e); cleanup(); process.exit(1); }); |