diff options
Diffstat (limited to 'tests')
| -rw-r--r-- | tests/browser.mjs | 91 | ||||
| -rw-r--r-- | tests/check.py | 145 |
2 files changed, 236 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)); +} diff --git a/tests/check.py b/tests/check.py index e0d5996..51fbf7f 100644 --- a/tests/check.py +++ b/tests/check.py @@ -20,6 +20,10 @@ none in the examples. 7. Painting: the bytes the TUI sends, replayed by a small terminal emulator, give the very frame the dump shows, while sending only what changed. +8. Web: the GUI surface's screen JSON matches the model after random keys, + text and row clicks, holds only the rows asked for around the focus, and + every open page hears of each change. In headless Chromium (when node and + chromium are there) the page itself takes keys, a click and typed text. """ import argparse import json @@ -368,6 +372,119 @@ def painting(vega, module, seeds, sizes, steps=200): return compared, first_mean, later_mean +def web(vega, module, work, seeds, steps=150, visible=4): + """The web surface against the model: POST keys, text and row clicks, then + the screen JSON's title, field, rows, focus and the window of items.""" + import http.client + import signal + import threading + compared = 0 + for seed in seeds: + store = work / f'web-{seed}' + process = subprocess.Popen([str(vega), 'web', str(module), '--port', '0', '--store', str(store)], + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + port = json.loads(process.stdout.readline())['port'] + connection = http.client.HTTPConnection('127.0.0.1', port, timeout=10) + + def call(method, path, body=None): + connection.request(method, path, body.encode() if isinstance(body, str) else body) + response = connection.getresponse() + return response.status, response.read() + + heard = [] + stream = http.client.HTTPConnection('127.0.0.1', port, timeout=10) + stream.request('GET', '/events') + events = stream.getresponse() + assert events.getheader('Content-Type') == 'text/event-stream' + listener = threading.Thread(target=lambda: [heard.append(line) for line in iter(events.readline, b'') + if line.startswith(b'data:')], daemon=True) + listener.start() + status, page = call('GET', '/') + assert status == 200 and b'EventSource' in page and b"role:'listbox'" in page, page[:80] + assert call('GET', '/nowhere')[0] == 404 + rng, model, first, part = random.Random(seed + 900), Todo(), 0, None + for _ in range(steps): + roll = rng.random() + if roll < 0.3: + word = rng.choice(WORDS) + status, answer = call('POST', '/text', word) + model.event('add', word) + elif roll < 0.45 and part is not None: + row = rng.randrange(len(model.tasks) + 1) + status, answer = call('POST', '/point', f'{part} {row}') + assert json.loads(answer)['ok'] == model.event('point', row), (seed, row) + else: + name = rng.choice(list(KEYS)) + status, answer = call('POST', '/key', KEYS[name]) + assert json.loads(answer)['ok'] == model.event(name), (seed, name) + assert status == 200 + status, body = call('GET', f'/screen?rows={visible}&first={first}') + screen = json.loads(body) + tasks, n = model.tasks, len(model.tasks) + (listed,) = screen['parts'] + part = listed['part'] + if model.at < first: first = model.at + if model.at >= first + visible: first = model.at - visible + 1 + if first + visible > n: first = max(n - visible, 0) + assert screen['title'] == 'To do' and screen['fields'] == [{'name': 'left', 'text': f'{n} tasks'}], screen + assert (listed['rows'], listed['focus'], listed['first']) == (n, model.at, first), (seed, listed, model.at) + window = tasks[first:first + visible] + assert [(item['row'], item['label'], item['badge'], item['tone']) for item in listed['items']] == \ + [(first + i, task['title'], Todo.badge(task), Todo.tone(task)) for i, task in enumerate(window)], seed + assert screen['prompt'] == 'add' and listed['pointable'] and listed['kind'] == 'list' + compared += 1 + process.send_signal(signal.SIGTERM) + assert process.wait(timeout=10) == 0, process.stderr.read() + listener.join(timeout=5) + assert len(heard) > steps // 2, len(heard) # a change each time, more or less merged + again = run(vega, 'cli', module, '--store', store, stdin='show\n').stdout.splitlines() + assert again == model.cli(), (again, model.cli()) + return compared + + +class WebSurface: + """vega web on a free port; stops with SIGTERM.""" + + def __init__(self, vega, module, *args): + self.process = subprocess.Popen([str(vega), 'web', str(module), '--port', '0', *map(str, args)], + stdout=subprocess.PIPE, stderr=subprocess.PIPE) + self.port = json.loads(self.process.stdout.readline())['port'] + + def call(self, method, path, body=None): + import http.client + connection = http.client.HTTPConnection('127.0.0.1', self.port, timeout=10) + try: + connection.request(method, path, body.encode() if isinstance(body, str) else body) + response = connection.getresponse() + return response.status, response.read() + finally: + connection.close() + + def stop(self): + import signal + self.process.send_signal(signal.SIGTERM) + assert self.process.wait(timeout=10) == 0, self.process.stderr.read() + + +def browser(vega, module, work): + """The page in headless Chromium: keys, a click on a row, typed text.""" + import shutil + if not shutil.which('node') or not shutil.which('chromium'): + return 'skipped' + store = work / 'browser-store' + assert run(vega, 'cli', module, '--store', store, stdin='text 61\ntext 62\ntext 63\n').returncode == 0 + surface = WebSurface(vega, module, '--store', store) + try: + result = run('node', HERE / 'browser.mjs', f'http://127.0.0.1:{surface.port}/') + finally: + surface.stop() + outcome = json.loads(result.stdout) + assert result.returncode == 0 and outcome['ok'], outcome + after = run(vega, 'cli', module, '--store', store, stdin='show\n').stdout.splitlines() + assert after == ['To do', 'left: 4 tasks', 'tasks_list (4)', ' a [done] (muted)', ' b', ' c', '> 브라우저'], after + return len(outcome['steps']) + + def scores(vega, module): joins = ['ann', '범수', 'Zoë'] script = [f'text {name.encode().hex()}' for name in joins] @@ -383,6 +500,19 @@ def scores(vega, module): '| 범수 3 gold\tgood'], tui say = run(vega, 'say', module, stdin='show\n' + '\n'.join(script) + '\n').stdout.splitlines() assert 'Item 1 of 3: danger, name ann, score -1, rank -.' in say, say + # The web surface: a table's columns and cells in order, with tones. + surface = WebSurface(vega, module) + try: + for line in script[:-1]: + command, argument = line.split() + if command == 'text': surface.call('POST', '/text', bytes.fromhex(argument)) + else: surface.call('POST', '/key', argument) + board = json.loads(surface.call('GET', '/screen?rows=10')[1])['parts'][0] + finally: + surface.stop() + assert board['kind'] == 'table' and board['columns'] == ['name', 'score', 'rank'] and board['focus'] == 0, board + assert [(item['cells'], item['tone']) for item in board['items']] == [ + (['ann', '-1', '-'], 'danger'), (['범수', '3', 'gold'], 'good'), (['Zoë', '0', '-'], 'normal')], board return len(out) @@ -517,6 +647,19 @@ def robustness(vega, aup, work, rounds): result = run(vega, surface[0], module, *surface[1:], stdin=text) assert result.returncode == 0, (path, surface, result.stderr[-400:]) runs += 1 + # The web surface under random posts: every answer well formed, and a clean stop. + surface = WebSurface(vega, module) + try: + for _ in range(40): + roll = rng.random() + if roll < 0.3: surface.call('POST', '/text', rng.choice(WORDS)) + elif roll < 0.4: surface.call('POST', '/point', f'{rng.randrange(4)} {rng.randrange(4)}') + else: surface.call('POST', '/key', rng.choice(keys)) + status, body = surface.call('GET', f'/screen?rows={rng.randrange(0, 30)}&first={rng.randrange(9)},1') + assert status == 200 and isinstance(json.loads(body)['parts'], list), (path, body[:200]) + finally: + surface.stop() + runs += 1 return len(examples), runs @@ -543,6 +686,8 @@ def main(): report['table_lines'] = scores(vega, modules['scores']) report['painted_frames'], report['first_paint_bytes'], report['later_paint_bytes'] = \ painting(vega, modules['todo'], range(args.seeds), [(40, 12), (24, 9), (80, 30)]) + report['web_screens'] = web(vega, modules['todo'], work, range(args.seeds)) + report['browser_steps'] = browser(vega, modules['todo'], work) report['interactive_sessions'] = interactive(vega, modules['todo']) + \ interactive(vega, modules['todo'], work / 'interactive-store') report['stored_surface_lines'] = stored_surfaces(vega, modules['todo'], work, range(args.seeds)) |
