aboutsummaryrefslogtreecommitdiffstats
path: root/tests
diff options
context:
space:
mode:
Diffstat (limited to 'tests')
-rw-r--r--tests/browser.mjs91
-rw-r--r--tests/check.py145
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))