1// Isolated gateway only; creates a retained ephemeral session per viewport.
2const { chromium } = require(process.env.PLAYWRIGHT_MODULE || 'playwright');
3const { execFileSync } = require('node:child_process');
4(async () => {
5 if (!process.env.VEV_BINARY || !process.env.VEV_ENV) throw new Error('VEV_BINARY and isolated VEV_ENV are required');
6 const output = execFileSync(process.env.VEV_BINARY, ['--web-daemon'], { encoding: 'utf8' });
7 const token = output.match(/#token=([A-Za-z0-9_-]+)/)[1];
8 const browser = await chromium.launch({ executablePath: process.env.CHROMIUM_PATH });
9 try {
10 for (const viewport of [{width:800,height:500}, {width:1600,height:1000}, {width:2560,height:1440}, {width:3840,height:2160}]) {
11 const page = await browser.newPage({viewport});
12 if (process.env.WEB_APP_JS) await page.route('**/app.js', route => route.fulfill({path:process.env.WEB_APP_JS,contentType:'text/javascript'}));
13 if (process.env.WEB_RUNTIME_JS) await page.route('**/terminal.js', route => route.fulfill({path:process.env.WEB_RUNTIME_JS,contentType:'text/javascript'}));
14 await page.addInitScript(() => {
15 const NativeWebSocket = window.WebSocket;
16 window.wire = [];
17 window.WebSocket = class extends NativeWebSocket {
18 constructor(...args) {
19 super(...args);
20 this.addEventListener('message', event => {
21 const entry = {received:performance.now(), bytes:event.data.length};
22 window.wire.push(entry);
23 queueMicrotask(() => { entry.applied = performance.now(); });
24 });
25 }
26 };
27 });
28 await page.goto(`http://127.0.0.1:8778/#token=${token}`);
29 await page.waitForFunction(() => document.querySelector('#status').textContent === 'Connected');
30 await page.waitForTimeout(1000);
31 if (process.env.WEB_LAYER) await page.evaluate(() => {
32 for (const rule of ['#terminal .vev-terminal__cell { overflow: visible; }', '#terminal .vev-terminal__row { transform: translateZ(0); }']) document.styleSheets[0].insertRule(rule, document.styleSheets[0].cssRules.length);
33 });
34 const cdp = await page.context().newCDPSession(page);
35 await cdp.send('Performance.enable');
36 const trace = [];
37 if (process.env.WEB_PROFILE && viewport.width === 3840) {
38 cdp.on('Tracing.dataCollected', ({value}) => trace.push(...value));
39 await cdp.send('Tracing.start', {categories:'devtools.timeline', transferMode:'ReportEvents'});
40 await cdp.send('Profiler.enable');
41 await cdp.send('Profiler.start');
42 }
43 const metricsBefore = await cdp.send('Performance.getMetrics');
44 const samples = [];
45 let expected = '';
46 for (const key of 'abcdefghij') {
47 expected += key;
48 await page.evaluate(({key, expected}) => {
49 window.sample = new Promise(resolve => {
50 const output = document.querySelector('.vev-terminal__accessible-output');
51 const before = output.textContent;
52 const start = performance.now();
53 const observer = new MutationObserver(() => {
54 if (output.textContent !== before && output.textContent.includes(expected)) {
55 observer.disconnect();
56 const last = window.wire.at(-1);
57 resolve({total:performance.now()-start, receive:last.received-start, apply:performance.now()-last.received, bytes:last.bytes});
58 }
59 });
60 observer.observe(output, {childList:true,subtree:true,characterData:true});
61 document.querySelector('textarea').dispatchEvent(new InputEvent('beforeinput', {data:key,inputType:'insertText',bubbles:true,cancelable:true}));
62 });
63 }, {key, expected});
64 samples.push(await page.evaluate(() => Promise.race([window.sample, new Promise((_, reject) => setTimeout(() => reject(new Error('input echo timeout')), 5000))])));
65 }
66 if (process.env.WEB_PROFILE && viewport.width === 3840) {
67 const completed = new Promise(resolve => cdp.once('Tracing.tracingComplete', resolve));
68 await cdp.send('Tracing.end');
69 await completed;
70 const durations = {};
71 for (const event of trace) if (event.ph === 'X' && event.dur) durations[event.name] = (durations[event.name] || 0) + event.dur / 1000;
72 console.log('Trace ms', Object.entries(durations).sort((a,b)=>b[1]-a[1]).slice(0,20));
73 const {profile} = await cdp.send('Profiler.stop');
74 const counts = new Map();
75 for (const id of profile.samples || []) counts.set(id, (counts.get(id) || 0)+1);
76 console.log('CPU samples', profile.nodes.map(node => ({name:node.callFrame.functionName, url:node.callFrame.url, count:counts.get(node.id)||0})).sort((a,b)=>b.count-a.count).slice(0,15));
77 }
78 const metricsAfter = await cdp.send('Performance.getMetrics');
79 const costs = {};
80 for (const metric of metricsAfter.metrics) {
81 if (['LayoutDuration','RecalcStyleDuration','ScriptDuration','TaskDuration','LayoutCount','RecalcStyleCount'].includes(metric.name)) {
82 costs[metric.name] = metric.value - metricsBefore.metrics.find(before => before.name === metric.name).value;
83 }
84 }
85 console.log(JSON.stringify({viewport, samplesMs:samples, costs}));
86 await page.keyboard.press('Control+c');
87 const action = async (name, text) => {
88 await page.evaluate(({name, text}) => {
89 window.actionSample = new Promise(resolve => {
90 const output = document.querySelector('.vev-terminal__accessible-output');
91 const start = performance.now();
92 const observer = new MutationObserver(() => {
93 if (output.textContent.includes(text)) {
94 observer.disconnect();
95 const last = window.wire.at(-1);
96 resolve({total:performance.now()-start,receive:last.received-start,apply:performance.now()-last.received,bytes:last.bytes});
97 }
98 });
99 observer.observe(output, {childList:true,subtree:true});
100 if (name === 'palette') document.querySelector('#palette').click();
101 else document.querySelector('textarea').dispatchEvent(new KeyboardEvent('keydown', {key:'Enter',code:'Enter',bubbles:true,cancelable:true}));
102 });
103 }, {name, text});
104 const ms = await page.evaluate(() => Promise.race([window.actionSample, new Promise((_, reject) => setTimeout(() => reject(new Error('action timeout')), 5000))]));
105 console.log(JSON.stringify({viewport, action:name, ms}));
106 };
107 await action('palette', 'Commands');
108 await page.keyboard.type('SPR');
109 await page.waitForTimeout(300);
110 await action('split', '│');
111 await page.close();
112 }
113 } finally { await browser.close(); }
114})().catch(error => { console.error(error); process.exitCode=1; });