284 lines
23 KiB
JavaScript
284 lines
23 KiB
JavaScript
// Live check of string packs on signed-in x.com, in the running release app (started with
|
|
// WEBVIEW2_ADDITIONAL_BROWSER_ARGUMENTS=--remote-debugging-port=<port>). Plain CDP: one tab of its
|
|
// own, opened and closed through the HTTP endpoint, and one websocket to that tab only.
|
|
//
|
|
// node scripts/strings-live-check.mjs <port> [outDir] [public profile handle]
|
|
//
|
|
// It works in a background tab of its own in the app's signed-in profile and only reads: no like,
|
|
// follow, post, message or setting; the only clicks are none at all (pages are loaded by URL). The
|
|
// freshly built X runtime (src-tauri/x-runtime.js, `node scripts/build-inject.mjs`) is injected
|
|
// at document start the way the app injects it, and the test pack is put in the check's own tab's
|
|
// session storage (`wayBetter.stringPacks`: no other tab sees it) and removed at the end.
|
|
//
|
|
// Checks: X's own text first (baseline); with the pack, strings change, nothing of the original
|
|
// flashes, a second registration and a reload change nothing further; without the pack again, the
|
|
// page's own text is back exactly. Screenshots are of the public profile page only, clipped to the
|
|
// navigation labels and the profile column (never the account button, never the home timeline).
|
|
import { execFileSync } from 'node:child_process';
|
|
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
import { createRequire } from 'node:module';
|
|
import { dirname, join } from 'node:path';
|
|
import { fileURLToPath } from 'node:url';
|
|
|
|
const root = join(dirname(fileURLToPath(import.meta.url)), '..');
|
|
const [port = '9477', out = join(root, '../../verification/string-packs'), handle = 'X'] = process.argv.slice(2);
|
|
const runtime = readFileSync(join(root, 'src-tauri/x-runtime.js'), 'utf8');
|
|
const pack = JSON.parse(readFileSync(join(root, 'strings/packs/testish.strings.json'), 'utf8'));
|
|
const inventory = JSON.parse(readFileSync(join(root, 'strings/x.web.inventory.json'), 'utf8'));
|
|
const { sliceInventory } = await import('../../../waybetter/packages/runtime/src/stringPacks.ts');
|
|
/** What the page gets: the pack and the slice of the inventory its keys need. */
|
|
const delivery = { inventory: sliceInventory(inventory, Object.keys(pack.strings)), packs: [pack] };
|
|
const KEY = 'wayBetter.stringPacks';
|
|
const wait = ms => new Promise(resolve => setTimeout(resolve, ms));
|
|
const lines = [];
|
|
const log = line => { lines.push(line); console.log(line); };
|
|
const check = (name, ok, detail = '') => log(`${ok ? 'PASS' : 'FAIL'} ${name}${detail ? ` (${detail})` : ''}`);
|
|
const alive = () => execFileSync('powershell', ['-NoProfile', '-Command', '(Get-Process better-twttr -ErrorAction SilentlyContinue | Measure-Object).Count']).toString().trim();
|
|
|
|
// ---- CDP: one tab of our own (HTTP endpoint) and one websocket to that tab only ---------------
|
|
const endpoint = `http://127.0.0.1:${port}`;
|
|
const pages = async () => (await (await fetch(`${endpoint}/json/list`)).json()).filter(target => target.type === 'page').length;
|
|
const pagesBefore = await pages();
|
|
const own = await (await fetch(`${endpoint}/json/new?about:blank`, { method: 'PUT' })).json();
|
|
const ws = new WebSocket(own.webSocketDebuggerUrl);
|
|
await new Promise((resolve, reject) => { ws.onopen = resolve; ws.onerror = reject; });
|
|
let seq = 0;
|
|
const pending = new Map();
|
|
const cdp = (method, params = {}) => new Promise((resolve, reject) => {
|
|
const id = ++seq;
|
|
const timer = setTimeout(() => { pending.delete(id); reject(new Error(`${method}: no answer in 60 s`)); }, 60000);
|
|
pending.set(id, message => { clearTimeout(timer); if (message.error) reject(new Error(`${method}: ${message.error.message}`)); else resolve(message.result); });
|
|
ws.send(JSON.stringify({ id, method, params }));
|
|
});
|
|
ws.onmessage = event => {
|
|
const message = JSON.parse(event.data);
|
|
if (message.id) { pending.get(message.id)?.(message); pending.delete(message.id); }
|
|
// A dialog this check caused must not hang the shared app.
|
|
else if (message.method === 'Page.javascriptDialogOpening') cdp('Page.handleJavaScriptDialog', { accept: false }).catch(() => {});
|
|
};
|
|
const evaluate = async expression => {
|
|
const result = await cdp('Runtime.evaluate', { expression, awaitPromise: true, returnByValue: true, timeout: 45000 });
|
|
if (result.exceptionDetails) throw new Error(result.exceptionDetails.exception?.description ?? result.exceptionDetails.text);
|
|
return result.result?.value;
|
|
};
|
|
|
|
// ---- page helpers -------------------------------------------------------------------------
|
|
/** Records every short text the page ever shows, with the time it was first seen. */
|
|
const WATCHER = `(() => {
|
|
if (window.top !== window || window.__wbSeen) return;
|
|
const seen = window.__wbSeen = new Map();
|
|
const note = value => { if (value && value.length <= 60 && seen.size < 6000 && !seen.has(value)) seen.set(value, Math.round(performance.now())); };
|
|
new MutationObserver(records => {
|
|
for (const record of records) {
|
|
if (record.type === 'characterData') note(record.target.nodeValue);
|
|
for (const node of record.addedNodes) {
|
|
if (node.nodeType === 3) note(node.nodeValue);
|
|
else if (node.nodeType === 1) { const walker = document.createTreeWalker(node, 4); while (walker.nextNode()) note(walker.currentNode.nodeValue); }
|
|
}
|
|
}
|
|
}).observe(document, { subtree: true, childList: true, characterData: true });
|
|
})();`;
|
|
/** The page's own wording: text and labels of the navigation and of the main column outside posts. */
|
|
const CHROME = `(() => {
|
|
const out = [];
|
|
// Not X's wording: posts, the account button, and what the app's own runtime adds or rewords (its
|
|
// navigation item, the "wording" option's buttons).
|
|
const skip = '[data-testid="SideNav_AccountSwitcher_Button"], [data-testid="cellInnerDiv"], article, script, style, [data-testid="AppTabBar_BetterTwttr"], [data-bt-retext], [data-testid="SideNav_NewTweet_Button"], button[data-testid^="tweetButton"]';
|
|
for (const root of [document.querySelector('header[role="banner"]'), document.querySelector('[data-testid="primaryColumn"]')]) {
|
|
if (!root) { out.push('<missing root>'); continue; }
|
|
const walker = document.createTreeWalker(root, 4);
|
|
while (walker.nextNode()) { const node = walker.currentNode; const value = node.nodeValue.trim(); if (value && !node.parentElement.closest(skip)) out.push('t:' + value); }
|
|
for (const element of root.querySelectorAll('[aria-label], [title], [placeholder]')) {
|
|
if (element.closest(skip)) continue;
|
|
for (const name of ['aria-label', 'title', 'placeholder']) if (element.hasAttribute(name)) out.push(name[0] + ':' + element.getAttribute(name));
|
|
}
|
|
}
|
|
return out;
|
|
})()`;
|
|
/** Loaded: the navigation, the main column and its first posts are there, and the wording stopped changing. */
|
|
const ready = async () => {
|
|
let last = '';
|
|
let same = 0;
|
|
for (let i = 0; i < 90; i += 1) {
|
|
await wait(700);
|
|
const ok = await evaluate(`!!document.querySelector('header[role="banner"] nav a') && !!document.querySelector('[data-testid="primaryColumn"] [role="tablist"], [data-testid="primaryColumn"] [data-testid="UserName"]')
|
|
&& !!document.querySelector('[data-testid="primaryColumn"] [data-testid="cellInnerDiv"] article')`).catch(() => false);
|
|
if (!ok) { same = 0; continue; }
|
|
const now = JSON.stringify(await evaluate(CHROME)).replace(/[d.,]+[KMB]?/g, '#');
|
|
same = now === last ? same + 1 : 0;
|
|
last = now;
|
|
if (same >= 2) return true;
|
|
}
|
|
return false;
|
|
};
|
|
const open = async path => { await cdp('Page.navigate', { url: `https://x.com${path}` }); return ready(); };
|
|
const scripts = [];
|
|
const inject = async sources => {
|
|
for (const identifier of scripts.splice(0)) await cdp('Page.removeScriptToEvaluateOnNewDocument', { identifier });
|
|
for (const source of sources) scripts.push((await cdp('Page.addScriptToEvaluateOnNewDocument', { source })).identifier);
|
|
};
|
|
const storePack = on => evaluate(on ? `sessionStorage.setItem(${JSON.stringify(KEY)}, ${JSON.stringify(JSON.stringify(delivery))})` : `sessionStorage.removeItem(${JSON.stringify(KEY)})`);
|
|
const report = () => evaluate('window.wayBetter?.stringPacks?.report?.() ?? null');
|
|
/** Text diff of two chrome lists as multisets: what only a has, what only b has. */
|
|
const differ = (a, b) => {
|
|
const count = list => list.reduce((map, item) => map.set(item, (map.get(item) ?? 0) + 1), new Map());
|
|
const [ca, cb] = [count(a), count(b)];
|
|
const only = (x, y) => [...x].flatMap(([item, n]) => (n > (y.get(item) ?? 0) ? [item] : []));
|
|
return { onlyA: only(ca, cb), onlyB: only(cb, ca) };
|
|
};
|
|
const numbersAside = list => list.map(item => item.replace(/[\d.,]+[KMB]?/g, '#'));
|
|
|
|
mkdirSync(out, { recursive: true });
|
|
const summary = { when: new Date().toISOString(), build: null, language: null, pages: {} };
|
|
try {
|
|
await cdp('Page.enable');
|
|
await cdp('Emulation.setDeviceMetricsOverride', { width: 1360, height: 900, deviceScaleFactor: 1, mobile: false });
|
|
await open('/home');
|
|
await storePack(false);
|
|
const pagesOf = [['home', '/home'], ['profile', `/${handle}`]];
|
|
|
|
// 1. X's own text: the runtime injected, no pack stored.
|
|
await inject([WATCHER, runtime]);
|
|
const baseline = {};
|
|
for (const [name, path] of pagesOf) {
|
|
check(`${name}: page loaded (no pack)`, await open(path));
|
|
baseline[name] = await evaluate(CHROME);
|
|
const state = await evaluate(`({ lang: document.documentElement.lang, signedIn: !!document.querySelector('[data-testid="SideNav_AccountSwitcher_Button"]'), runtime: !!window.wayBetter, report: window.wayBetter?.stringPacks?.report?.() ?? null,
|
|
pushIsPlain: !Object.getOwnPropertyDescriptor(window.webpackChunk_twitter_responsive_web ?? [], 'push')?.get, main: [...document.scripts].map(s => s.src).find(s => /\\/main\\./.test(s))?.split('/').pop() })`);
|
|
summary.language = state.lang; summary.build = state.main;
|
|
check(`${name}: signed in, runtime present, nothing hooked without a pack`, state.signedIn && state.runtime && Array.isArray(state.report) && state.report.length === 0 && state.pushIsPlain, `lang ${state.lang}, ${baseline[name].length} chrome strings`);
|
|
}
|
|
|
|
// 2. With the pack.
|
|
await storePack(true);
|
|
const withPack = {};
|
|
for (const [name, path] of pagesOf) {
|
|
check(`${name}: page loaded (pack stored)`, await open(path));
|
|
withPack[name] = await evaluate(CHROME);
|
|
const [state] = (await report()) ?? [];
|
|
const diff = differ(baseline[name], withPack[name]);
|
|
const changedFrom = diff.onlyA.filter(item => !/\d/.test(item));
|
|
const packValues = new Set(Object.values(pack.strings).filter(value => !value.includes('{')));
|
|
const changedTo = diff.onlyB.filter(item => packValues.has(item.slice(2)) || /postle|Arrived/.test(item));
|
|
check(`${name}: hook ok, pack applied`, state?.status === 'ok' && state.engine.applied > 40, state ? `status ${state.status}, language ${state.languages.join('+')}, files ${JSON.stringify(state.seen)}, keys seen ${state.engine.seen}, applied ${state.engine.applied}, unlisted ${state.engine.unlisted.length}, mismatched ${state.engine.mismatched.length}, pack ${state.packs[0]?.report.valid}/${state.packs[0]?.report.total} valid` : 'no report');
|
|
check(`${name}: strings changed on screen`, changedTo.length >= 8, `${changedTo.length} chrome strings now in the test dialect, e.g. ${[...new Set(changedTo)].slice(0, 9).map(item => item.slice(2)).join(' | ')}`);
|
|
// Flash: was any original that the pack replaces ever shown during this load?
|
|
// Only originals that are gone from the page: a text that another, unreplaced key still shows is no flash.
|
|
const originals = [...new Set(changedFrom.filter(item => item.startsWith('t:') && !withPack[name].includes(item)).map(item => item.slice(2)))];
|
|
const flash = await evaluate(`(() => { const seen = window.__wbSeen ?? new Map(); const originals = ${JSON.stringify(originals)}; const values = ${JSON.stringify([...packValues])};
|
|
const first = list => list.map(v => seen.get(v)).filter(v => v !== undefined).sort((a, b) => a - b)[0] ?? null;
|
|
return { flashed: originals.filter(v => seen.has(v)).map(v => v + '@' + seen.get(v)), firstPack: first(values), texts: seen.size }; })()`);
|
|
check(`${name}: no flash of X's original text`, flash.flashed.length === 0, `${originals.length} replaced originals watched from document start, ${flash.flashed.length} ever shown${flash.flashed.length ? `: ${flash.flashed.slice(0, 6).join(', ')}` : ''}; first pack text at ${flash.firstPack} ms; ${flash.texts} texts recorded`);
|
|
summary.pages[name] = { chrome: baseline[name].length, changed: changedTo.length, originalsWatched: originals.length, flashed: flash.flashed.length, firstPackTextMs: flash.firstPack, report: state };
|
|
|
|
// A second application: X's language module runs again and registers every value once more.
|
|
const again = await evaluate(`(async () => {
|
|
let require; window.webpackChunk_twitter_responsive_web.push([[Symbol('wb-check')], {}, r => { require = r; }]);
|
|
const registry = Object.values(require.c).map(m => m.exports).find(e => e && typeof e === 'object' && typeof e._register === 'function');
|
|
const main = [...document.scripts].map(s => s.src).find(s => /\\/main\\./.test(s));
|
|
const text = await (await fetch(main)).text();
|
|
const lang = document.documentElement.lang;
|
|
const moduleId = new RegExp('"\\\\./' + lang + '":\\\\[(\\\\d+),').exec(text)?.[1];
|
|
if (!registry || !moduleId || !require.c[moduleId]) return { error: 'registry or language module not found', moduleId };
|
|
const ids = Object.getOwnPropertyNames(registry).filter(id => /^[a-z][0-9a-f]{7}$/.test(id));
|
|
// X's module makes new function objects every time it runs, so functions are compared by what
|
|
// they return for the same parameters, strings by value.
|
|
const params = { count: 2, formattedCount: '2', date: 'May 2020', joinDate: 'May 2020', country: 'Nowhere', name: 'n', screenName: 's', fullName: 'f' };
|
|
const packIds = new Set(${JSON.stringify(Object.keys(pack.strings))});
|
|
const snapshot = () => ids.map(id => { try { const value = registry[id]; return typeof value === 'string' ? value : packIds.has(id) && typeof value === 'function' ? 'fn:' + value(params) : typeof value; } catch { return undefined; } });
|
|
const first = snapshot();
|
|
const applied = window.wayBetter.stringPacks.report()[0].engine.applied;
|
|
delete require.c[moduleId];
|
|
require(moduleId);
|
|
const second = snapshot();
|
|
let changed = 0; for (let i = 0; i < ids.length; i += 1) if (first[i] !== second[i]) changed += 1;
|
|
const sample = ['ha8209bc', 'fa98627a'].map(id => registry[id]);
|
|
return { ids: ids.length, changed, applied, appliedAfter: window.wayBetter.stringPacks.report()[0].engine.applied, sample, message: typeof registry.e2ee95a1 === 'function' ? registry.e2ee95a1({ date: 'May 2020' }) : null, plural: typeof registry.i73fff1b === 'function' ? [registry.i73fff1b({ count: 1 }), registry.i73fff1b({ count: 35 })] : null };
|
|
})()`);
|
|
await wait(1500);
|
|
const afterSecond = await evaluate(CHROME);
|
|
const second = differ(withPack[name], afterSecond);
|
|
check(`${name}: a second application changes nothing further`, !again.error && again.changed === 0 && again.applied === again.appliedAfter && second.onlyA.length + second.onlyB.length === 0,
|
|
again.error ?? `${again.ids} registry values compared (strings by value, pack messages by their output): ${again.changed} changed; applied ${again.applied} -> ${again.appliedAfter}; chrome strings differing ${second.onlyA.length + second.onlyB.length}; samples ${again.sample.join(', ')} | ${again.message} | ${again.plural?.join(', ')}`);
|
|
summary.pages[name].second = again;
|
|
if (name === 'home') {
|
|
// The emoji names arrive later in their own file, through the same setter.
|
|
let files = {};
|
|
for (let i = 0; i < 24 && !files.emoji; i += 1) { await wait(500); files = (await report())?.[0]?.seen ?? {}; }
|
|
const state = (await report())?.[0];
|
|
check('home: the emoji file went through the same hook', !!files.emoji, `files ${JSON.stringify(files)}, keys seen ${state?.engine.seen}, unlisted ${state?.engine.unlisted.length}`);
|
|
summary.pages[name].emoji = { files, seen: state?.engine.seen, unlisted: state?.engine.unlisted.length };
|
|
}
|
|
}
|
|
|
|
// The pack again after a reload: the same result.
|
|
{
|
|
check('profile: reloaded (pack stored)', await open(`/${handle}`));
|
|
const reloaded = differ(numbersAside(withPack.profile), numbersAside(await evaluate(CHROME)));
|
|
check('profile: a reload gives the same result', reloaded.onlyA.length + reloaded.onlyB.length === 0, `${reloaded.onlyA.length + reloaded.onlyB.length} chrome strings differ`);
|
|
// Public profile page only; the navigation labels and the profile column, never the account button.
|
|
// The row naming accounts the signed-in account follows is personal: kept out of the picture.
|
|
await evaluate(`for (const el of document.querySelectorAll('[data-testid="primaryColumn"] a[href$="/followers_you_follow"]')) el.style.visibility = 'hidden';`);
|
|
const clip = await evaluate(`(() => { const nav = document.querySelector('header[role="banner"] nav').getBoundingClientRect(); const column = document.querySelector('[data-testid="primaryColumn"]').getBoundingClientRect();
|
|
const account = document.querySelector('[data-testid="SideNav_AccountSwitcher_Button"]')?.getBoundingClientRect(); const bottom = Math.min(760, (account?.top ?? 760) - 12);
|
|
return { x: Math.floor(nav.left), y: 0, width: Math.ceil(column.right - nav.left), height: Math.floor(bottom), scale: 1 }; })()`);
|
|
const shot = await cdp('Page.captureScreenshot', { format: 'png', clip }).catch(error => ({ error: String(error) }));
|
|
if (shot.data) { writeFileSync(join(out, 'profile-with-pack.png'), Buffer.from(shot.data, 'base64')); log(`shot ${join(out, 'profile-with-pack.png').replace(/\\/g, '/').split('/').slice(-3).join('/')}`); } else log(`shot failed: ${shot.error}`);
|
|
}
|
|
|
|
// Cost, measured in this page: the chunk-array wrapper per push and the engine per registered key.
|
|
{
|
|
const esbuild = createRequire(join(root, 'package.json'))('esbuild');
|
|
const bench = await esbuild.build({
|
|
bundle: true, write: false, format: 'iife', minify: true, absWorkingDir: root,
|
|
stdin: { resolveDir: root, loader: 'ts', contents: `
|
|
import { watchChunkArray } from '../../waybetter/packages/runtime/src/stringPackHook.ts';
|
|
import { compilePack, createStringEngine, inventoryLookup } from '../../waybetter/packages/runtime/src/stringPacks.ts';
|
|
import { languageFile } from './src/inject/stringPacks.ts';
|
|
import X_WEB_INVENTORY from './strings/x.web.page.json';
|
|
(globalThis as any).__wbBench = (pack: unknown, slice: any) => {
|
|
const N = 200000; const median = (f: () => number) => [f(), f(), f(), f(), f(), f(), f()].sort((a, b) => a - b)[0];
|
|
const chunks = () => Array.from({ length: N }, (_, i) => [[i], {}]);
|
|
const plain = median(() => { const list = chunks(); const a: unknown[] = []; const t = performance.now(); for (let i = 0; i < N; i += 1) a.push(list[i]); return performance.now() - t; });
|
|
const hooked = median(() => { const list = chunks(); const holder: any = { document }; let hits = 0; watchChunkArray(holder, 'a', (_c, script) => { if (languageFile(script)) hits += 1; }); const a = holder.a; const t = performance.now(); for (let i = 0; i < N; i += 1) a.push(list[i]); return performance.now() - t; });
|
|
const t0 = performance.now(); const compiled = compilePack(pack, slice); inventoryLookup(slice)('x'); const setup = performance.now() - t0;
|
|
const ids = [...X_WEB_INVENTORY.text.split(' '), ...Object.keys(X_WEB_INVENTORY.message), ...X_WEB_INVENTORY.closed.split(' ')];
|
|
const fn = () => ''; const values = ids.map(id => (X_WEB_INVENTORY.message[id] !== undefined ? fn : 'x'));
|
|
const direct = median(() => { const table: Record<string, unknown> = {}; const t = performance.now(); for (let i = 0; i < ids.length; i += 1) table[ids[i]!] = values[i]; return performance.now() - t; });
|
|
const engine = median(() => { const e = createStringEngine(slice, [compiled]); const table: Record<string, unknown> = {}; const t = performance.now(); for (let i = 0; i < ids.length; i += 1) table[ids[i]!] = e.resolve(ids[i]!, values[i]); return performance.now() - t; });
|
|
return { N, plainNs: Math.round(plain * 1e6 / N), hookedNs: Math.round(hooked * 1e6 / N), setupMs: +setup.toFixed(2), keys: ids.length, directMs: +direct.toFixed(2), engineMs: +engine.toFixed(2) };
|
|
};` },
|
|
});
|
|
const cost = await evaluate(`${bench.outputFiles[0].text}; __wbBench(${JSON.stringify(pack)}, ${JSON.stringify(delivery.inventory)})`);
|
|
const objectAssign = await evaluate('Function.prototype.toString.call(Object.assign).includes("[native code]")');
|
|
log(`cost chunk push: ${cost.plainNs} ns plain, ${cost.hookedNs} ns through the wrapper (${cost.N} pushes, best of 7); engine: ${cost.keys} keys in ${cost.engineMs} ms vs ${cost.directMs} ms direct; pack compile against its inventory slice ${cost.setupMs} ms; Object.assign is native (untouched): ${objectAssign}`);
|
|
summary.cost = { ...cost, objectAssignNative: objectAssign };
|
|
}
|
|
|
|
// 3. Without the pack again: X's own text, exactly.
|
|
await storePack(false);
|
|
for (const [name, path] of pagesOf) {
|
|
check(`${name}: page loaded (pack removed)`, await open(path));
|
|
const restored = await evaluate(CHROME);
|
|
const exact = differ(baseline[name], restored);
|
|
const wording = differ(numbersAside(baseline[name]), numbersAside(restored));
|
|
const state = await evaluate(`({ report: window.wayBetter?.stringPacks?.report?.() ?? null, pushIsPlain: !Object.getOwnPropertyDescriptor(window.webpackChunk_twitter_responsive_web ?? [], 'push')?.get, dialect: document.body.innerText.includes('Hoom') })`);
|
|
check(`${name}: X's text is back exactly`, wording.onlyA.length + wording.onlyB.length === 0 && state.report.length === 0 && state.pushIsPlain && !state.dialect,
|
|
`${restored.length} chrome strings; wording differences ${wording.onlyA.length + wording.onlyB.length}; with live numbers ${exact.onlyA.length + exact.onlyB.length}${wording.onlyA.length + wording.onlyB.length ? `: ${[...wording.onlyA, ...wording.onlyB].slice(0, 6).map(item => item.length > 40 ? item.length + ' chars' : item).join(' | ')}` : ''}; nothing hooked`);
|
|
summary.pages[name].restored = { wordingDifferences: wording.onlyA.length + wording.onlyB.length, withNumbers: exact.onlyA.length + exact.onlyB.length };
|
|
}
|
|
} finally {
|
|
await storePack(false).catch(() => {});
|
|
const left = await evaluate(`sessionStorage.getItem(${JSON.stringify(KEY)})`).catch(() => 'unknown');
|
|
log(`cleanup pack in the tab's session storage: ${left === null ? 'removed' : left}`);
|
|
log(`pages before closing the check's tab: ${await pages().catch(() => '?')} (${pagesBefore} before the check)`);
|
|
ws.close();
|
|
await fetch(`${endpoint}/json/close/${own.id}`).catch(() => {});
|
|
await wait(1500);
|
|
log(`pages after: ${await pages().catch(() => '?')}; app processes alive: ${alive()}`);
|
|
}
|
|
const failed = lines.filter(line => line.startsWith('FAIL')).length;
|
|
log(`${lines.filter(line => line.startsWith('PASS')).length} passed, ${failed} failed`);
|
|
writeFileSync(join(out, 'live-check.json'), `${JSON.stringify(summary, null, 1)}\n`);
|
|
writeFileSync(join(out, 'live-check.txt'), `${lines.join('\n')}\n`);
|
|
process.exitCode = failed ? 1 : 0;
|