Oregami
Repositories/oxedyne/daimond

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.
22import fs from 'node:fs';
23import os from 'node:os';
24import path from 'node:path';
25import { spawn } from 'node:child_process';
26import { fileURLToPath, pathToFileURL } from 'node:url';
27import { requireFreshGateway, procLog, GWDIR, GWBIN, openBeta } from './gwbin.mjs';
28import { signInFresh } from './session.mjs';
29
30const HERE = path.dirname(fileURLToPath(import.meta.url));
31const 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/.
35const GW_PORT = Number(process.env.DAIMOND_GW_PORT || 9418);
36const APP_PORT = Number(process.env.DAIMOND_PORT || 8793);
37const GW = `http://127.0.0.1:${GW_PORT}`;
38const APP = `http://localhost:${APP_PORT}`;
39const SCRATCH = process.env.DAIMOND_SCRATCH || path.join(os.homedir(), '.cache/daimond');
40const LANE = path.join(SCRATCH, 'laneA');
41const WORK = path.join(LANE, 'shot_betatier-gw');
42const SHOTS = path.join(LANE, 'shots');
43const GW_LOG = procLog('shot_betatier');
44const SRV_LOG = procLog('shot_betatier', 'serve');
45const PW = process.env.DAIMOND_PW
46 || path.join(os.homedir(), '.red-pw/node_modules/playwright-core/index.mjs');
47const CHROME = process.env.DAIMOND_CHROME
48 || `${process.env.HOME}/.cache/ms-playwright/chromium-1229/chrome-linux64/chrome`;
49
50const sleep = ms => new Promise(r => setTimeout(r, ms));
51const procs = [];
52function launch(cmd, args, opts) { const p = spawn(cmd, args, opts); procs.push(p); return p; }
53function cleanup() { for (const p of procs) { try { p.kill('SIGKILL'); } catch (e) {} } }
54async 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
63const ok = [], bad = [];
64const check = (name, pass, detail) => {
65 (pass ? ok : bad).push(name);
66 console.log((pass ? ' ok ' : ' FAIL ') + name + (detail ? ' — ' + detail : ''));
67};
68
69requireFreshGateway();
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.
74function 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
92let gw = null;
93async 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); });