aboutsummaryrefslogtreecommitdiffstats
path: root/tests/browser.mjs
diff options
context:
space:
mode:
Diffstat (limited to 'tests/browser.mjs')
-rw-r--r--tests/browser.mjs91
1 files changed, 91 insertions, 0 deletions
diff --git a/tests/browser.mjs b/tests/browser.mjs
new file mode 100644
index 0000000..87ad584
--- /dev/null
+++ b/tests/browser.mjs
@@ -0,0 +1,91 @@
+// The web surface in a real browser: headless Chromium, driven over the
+// DevTools protocol with what a user does (keys, a click on a row, typing
+// Korean text and Enter), each step waiting for the page to show its effect.
+//
+// node tests/browser.mjs URL (the app: examples/todo.aup with tasks a, b, c)
+// Prints one JSON line: the steps that held.
+import { spawn } from 'node:child_process';
+import { mkdtempSync, rmSync } from 'node:fs';
+import { tmpdir } from 'node:os';
+import { join } from 'node:path';
+
+const url = process.argv[2];
+const profile = mkdtempSync(join(tmpdir(), 'vega-browser-'));
+const chromium = spawn('chromium', ['--headless=new', '--remote-debugging-port=0', `--user-data-dir=${profile}`,
+ '--no-first-run', '--no-default-browser-check', '--disable-gpu', '--window-size=900,700', 'about:blank'],
+ { stdio: ['ignore', 'ignore', 'pipe'] });
+const endpoint = await new Promise((resolve, reject) => {
+ let seen = '';
+ chromium.stderr.on('data', chunk => {
+ seen += chunk;
+ const found = seen.match(/DevTools listening on (ws:\/\/\S+)/);
+ if (found) resolve(found[1]);
+ });
+ chromium.on('exit', code => reject(new Error(`chromium exited ${code}: ${seen.slice(-400)}`)));
+ setTimeout(() => reject(new Error('chromium did not start')), 20000);
+});
+
+const socket = new WebSocket(endpoint);
+await new Promise(resolve => socket.addEventListener('open', resolve, { once: true }));
+let next = 0;
+const waiting = new Map();
+socket.addEventListener('message', event => {
+ const message = JSON.parse(event.data);
+ if (message.id && waiting.has(message.id)) {
+ const { resolve, reject } = waiting.get(message.id);
+ waiting.delete(message.id);
+ if (message.error) reject(new Error(JSON.stringify(message.error)));
+ else resolve(message.result);
+ }
+});
+let session;
+const send = (method, params = {}) => new Promise((resolve, reject) => {
+ const id = ++next;
+ waiting.set(id, { resolve, reject });
+ socket.send(JSON.stringify(session ? { id, method, params, sessionId: session } : { id, method, params }));
+});
+
+const steps = [];
+async function finish(ok, error) {
+ console.log(JSON.stringify({ ok, steps, error }));
+ chromium.kill();
+ rmSync(profile, { recursive: true, force: true });
+ process.exit(ok ? 0 : 1);
+}
+
+try {
+ const { targetId } = await send('Target.createTarget', { url });
+ ({ sessionId: session } = await send('Target.attachToTarget', { targetId, flatten: true }));
+ const evaluate = async expression => (await send('Runtime.evaluate', { expression, returnByValue: true })).result.value;
+ const until = async (what, expression) => {
+ for (const end = Date.now() + 10000; Date.now() < end;) {
+ if (await evaluate(expression)) { steps.push(what); return; }
+ await new Promise(resolve => setTimeout(resolve, 50));
+ }
+ throw new Error(`${what}: never true: ${expression}; page: ${await evaluate('document.body.innerText')}`);
+ };
+ const selected = `[...document.querySelectorAll('li')].findIndex(li => li.getAttribute('aria-selected') === 'true')`;
+ const key = async (code, key, keyCode, text) => {
+ await send('Input.dispatchKeyEvent', { type: 'keyDown', code, key, windowsVirtualKeyCode: keyCode, text });
+ await send('Input.dispatchKeyEvent', { type: 'keyUp', code, key, windowsVirtualKeyCode: keyCode });
+ };
+
+ await until('three tasks, the last focused', `document.querySelectorAll('li').length === 3 && ${selected} === 2`);
+ await until('title and field', `document.querySelector('h1').textContent === 'To do' && document.querySelector('dd').textContent === '3 tasks'`);
+ await key('ArrowUp', 'ArrowUp', 38);
+ await until('ArrowUp moves the focus', `${selected} === 1`);
+ const box = await evaluate(`(() => { const r = document.querySelectorAll('li')[0].getBoundingClientRect(); return [r.x + r.width / 2, r.y + r.height / 2]; })()`);
+ await send('Input.dispatchMouseEvent', { type: 'mousePressed', x: box[0], y: box[1], button: 'left', clickCount: 1 });
+ await send('Input.dispatchMouseEvent', { type: 'mouseReleased', x: box[0], y: box[1], button: 'left', clickCount: 1 });
+ await until('a click focuses its row', `${selected} === 0`);
+ await key('Space', ' ', 32, ' ');
+ await until('Space marks it done, muted', `document.querySelectorAll('li')[0].classList.contains('muted') && document.querySelector('li .badge')?.textContent === 'done'`);
+ await evaluate(`document.getElementById('entry').focus()`);
+ await send('Input.insertText', { text: '브라우저' });
+ await key('Enter', 'Enter', 13, '\r');
+ await until('typed text becomes a task', `document.querySelectorAll('li').length === 4 && document.querySelectorAll('li')[3].textContent === '브라우저' && ${selected} === 3`);
+ await until('the input is emptied', `document.getElementById('entry').value === ''`);
+ await finish(true);
+} catch (error) {
+ await finish(false, String(error.message || error));
+}