// Install screen + Store screenshots and flow checks on the preview harnesses (needs `vite dev`; // native install/store commands are mocked in src/shell/preview/storeMocks.ts with the real store // files from apps/web/public/store). Not a native test: signature, allow-list, size caps and // validation are Rust's (distribution.rs, waybetter-host plugins.rs) and tested there. // Usage: node scripts/store-shots.mjs [baseUrl] [outDir] import { chromium } from 'playwright'; import { mkdirSync } from 'node:fs'; import { join } from 'node:path'; const base = process.argv[2] ?? 'http://127.0.0.1:1420'; const out = process.argv[3] ?? join(import.meta.dirname, '..', '..', '..', 'verification', 'store'); mkdirSync(out, { recursive: true }); const failures = []; const check = (label, ok, detail = '') => { console.log(`${ok ? 'ok ' : 'FAIL'} ${label}${detail ? ` (${detail})` : ''}`); if (!ok) failures.push(label); }; const browser = await chromium.launch(); async function open(path, viewport = { width: 1280, height: 860 }) { const page = await browser.newPage({ viewport, deviceScaleFactor: 1 }); page.on('pageerror', error => failures.push(`page error: ${error.message}`)); const url = new URL(path, `${base}/`); if (!url.searchParams.has('lang')) url.searchParams.set('lang', 'en'); await page.goto(url.href); return page; } const card = (page, name) => page.locator('.wbs-card').filter({ has: page.locator('h3', { hasText: new RegExp(`^${name}$`) }) }); const shot = (page, name) => page.screenshot({ path: join(out, `${name}.png`) }); // 1. A waybetter://install link for a plugin arrives in the shell: the screen shows everything, and // Install stays disabled for a moment. { const page = await open('?shell&tabs=4&open=install&kind=plugin'); await page.waitForSelector('.wbi.is-ready', { timeout: 8000 }); const install = page.locator('.wbi-install'); check('link: Install is disabled at first', await install.isDisabled()); const text = await page.locator('.wbi').innerText(); check('link: name, author, version', text.includes('Reply Guard') && text.includes('by wayBetter') && /v\d+\.\d+\.\d+/.test(text)); check('link: source host shown', text.includes('Link: waybetter.fyi')); check('link: sha256 shown', /[0-9a-f]{64}/.test(text)); check('link: permissions listed', ['Storage', 'Dialogs', 'Your account', 'Account details', 'No network'].every(label => text.includes(label))); check('link: honest note about page access', text.includes('Permissions limit what the wayBetter API gives a plugin, not the page')); check('link: code preview', (await page.locator('.wbi-code .wbi-line').count()) > 20); // Focus starts on Cancel: Enter cancels, it never installs. await page.keyboard.press('Enter'); await page.waitForTimeout(200); check('link: Enter does not install', (await page.locator('.wbi.is-done').count()) === 0 && (await page.locator('.wbi-install').count()) === 0); await page.evaluate(() => window.__btEmit('install-link', { link: { kind: 'plugin', url: 'https://waybetter.fyi/store/plugins/bt-reply-guard.plugin.js' } })); await page.waitForSelector('.wbi.is-ready', { timeout: 8000 }); await page.waitForTimeout(1100); check('link: Install enabled after review time', !(await install.isDisabled())); await shot(page, 'install-plugin-link'); await page.locator('.wbi-body').evaluate(node => { node.scrollTop = node.scrollHeight; }); await shot(page, 'install-plugin-link-code'); await install.click(); await page.waitForSelector('.wbi.is-done', { timeout: 5000 }); check('link: installed state', (await page.locator('.wbi').innerText()).includes('Reply Guard is installed')); await shot(page, 'install-plugin-done'); await page.close(); } // 2. A theme link: live preview of the theme, file preview, "Use it now". { const page = await open('?shell&tabs=3&open=install&kind=theme&src=https://waybetter.fyi/store/themes/paper.json'); await page.waitForSelector('.wbi.is-ready', { timeout: 8000 }); const text = await page.locator('.wbi').innerText(); check('theme: name and preview', text.includes('Paper') && (await page.locator('.wbi-theme-preview').count()) === 1); check('theme: no permissions block', (await page.locator('.wbi-perms').count()) === 0); await page.waitForTimeout(1100); await shot(page, 'install-theme-link'); await page.keyboard.press('Escape'); check('theme: Esc cancels', (await page.locator('.wbi').count()) === 0); await page.close(); } // 3. A refused link: an explanation, nothing downloaded. { const page = await open('?shell&tabs=3&open=install-error'); await page.waitForSelector('.wbi.is-error', { timeout: 8000 }); check('refused link explained', (await page.locator('.wbi').innerText()).includes('only https links on waybetter.fyi')); await shot(page, 'install-link-refused'); await page.close(); } // 4. Dropping files on the window: a plugin file (legacy, no @permissions; its code calls fetch), // then a file that is neither. { const page = await open('?shell&tabs=3'); await page.waitForSelector('.bt-tab'); const drop = async (name, content, type = 'text/javascript') => { const data = await page.evaluateHandle(([n, c, t]) => { const dt = new DataTransfer(); dt.items.add(new File([c], n, { type: t })); return dt; }, [name, content, type]); await page.dispatchEvent('.bt-titlebar', 'dragenter', { dataTransfer: data }); await page.dispatchEvent('.bt-titlebar', 'dragover', { dataTransfer: data }); await page.waitForTimeout(150); return data; }; const plugin = '/**\n * @name Weather Badge\n * @description Shows the weather next to the search box.\n * @version 0.3.0\n * @author someone\n */\nmodule.exports = class {\n async start() {\n const r = await fetch("https://api.example/weather");\n this.el = document.body.appendChild(document.createElement("div"));\n this.el.textContent = (await r.json()).summary;\n }\n stop() { this.el?.remove(); }\n};\n'; const data = await drop('weather-badge.plugin.js', plugin); check('drop: window-wide drop target', (await page.locator('.wbi-drop').count()) === 1); await shot(page, 'drop-target'); await page.dispatchEvent('.bt-titlebar', 'drop', { dataTransfer: data }); await page.waitForSelector('.wbi.is-ready', { timeout: 8000 }); const text = await page.locator('.wbi').innerText(); check('drop: dropped-file source', text.includes('A file you dropped')); check('drop: legacy badge', text.includes('Legacy · full page access')); check('drop: source scan finding', text.includes('makes network requests (fetch)')); await page.waitForTimeout(1100); await shot(page, 'install-dropped-legacy-plugin'); await page.locator('.wbi-foot .bt-deck-secondary').click(); const other = await drop('photo.png', 'PNG', 'image/png'); await page.dispatchEvent('.bt-titlebar', 'drop', { dataTransfer: other }); await page.waitForSelector('.wbi.is-error', { timeout: 5000 }); check('drop: other files are explained, not installed', (await page.locator('.wbi').innerText()).includes('drop it on X’s composer')); await page.close(); const turkish = await open('?shell&tabs=3&lang=tr'); await turkish.waitForSelector('.bt-tab'); const turkishFile = await turkish.evaluateHandle(([name, content]) => { const transfer = new DataTransfer(); transfer.items.add(new File([content], name, { type: 'text/javascript' })); return transfer; }, ['weather-badge.plugin.js', plugin]); await turkish.dispatchEvent('.bt-titlebar', 'dragenter', { dataTransfer: turkishFile }); await turkish.dispatchEvent('.bt-titlebar', 'dragover', { dataTransfer: turkishFile }); await turkish.dispatchEvent('.bt-titlebar', 'drop', { dataTransfer: turkishFile }); await turkish.waitForSelector('.wbi.is-ready', { timeout: 8000 }); check('drop: Turkish source label', (await turkish.locator('.wbi-source strong').innerText()) === 'Bıraktığın bir dosya'); await turkish.close(); } // 5. The Store section: cards, search, categories, installed / update states, permission badges; // installing from it goes through the same screen. { const page = await open('?panel§ion=store', { width: 1180, height: 900 }); await page.waitForSelector('.wbs-card:not(.is-skeleton)', { timeout: 8000 }); await page.waitForTimeout(600); const cards = await page.locator('.wbs-card').count(); check('store: cards for every catalog item', cards >= 27, `${cards}`); check('store: signature status', (await page.locator('.wbs-status.is-ok').innerText()).includes('Signed catalog verified')); check('store: installed plugin', (await card(page, 'Thread Reader').innerText()).includes('Installed')); check('store: update available', (await card(page, 'Hide Blue Checks').innerText()).includes('Update to')); check('store: built-in theme', (await card(page, 'Nord').innerText()).includes('Built in')); check('store: updates banner', (await page.locator('.wbs-updates').count()) === 1); await shot(page, 'store'); await page.locator('.wbs-toolbar input').fill('regex'); check('store: search', (await page.locator('.wbs-card').count()) === 1); await page.locator('.wbs-toolbar input').fill(''); await page.locator('.wbs-toolbar [role="radiogroup"] button, .wbs-toolbar button', { hasText: 'Plugins' }).first().click(); await page.locator('.wbs-cats button', { hasText: 'Reading' }).click(); await page.waitForTimeout(200); await shot(page, 'store-plugins-reading'); check('store: category filter', (await page.locator('.wbs-card').count()) === 3); await page.locator('.wbs-cats button', { hasText: 'Everything' }).click(); await card(page, 'Copy as Markdown').locator('button', { hasText: 'Install' }).click(); await page.waitForSelector('.wbi.is-ready', { timeout: 8000 }); const text = await page.locator('.wbi').innerText(); check('store install: signed source', text.includes('wayBetter Store') && text.includes('Verified')); await page.waitForTimeout(1100); await shot(page, 'install-from-store'); await page.locator('.wbi-install').click(); await page.waitForSelector('.wbi.is-done'); await page.locator('.wbi-state-actions .bt-deck-primary').click(); await page.waitForTimeout(400); check('store: installed after confirming', (await card(page, 'Copy as Markdown').innerText()).includes('Installed')); // The Plugins list shows permissions, the legacy badge and where a plugin came from. await page.locator('.btp-nav button', { hasText: 'Plugins' }).click(); await page.waitForTimeout(300); const list = await page.locator('.btp-page').innerText(); check('plugins list: permission badges', list.includes('Clipboard') && list.includes('No network')); check('plugins list: legacy badge', list.includes('Legacy · full page access')); check('plugins list: store source', list.includes('Store')); await shot(page, 'plugins-permissions'); await page.close(); } // 6. Narrow panel and an unreachable store. { const page = await open('?panel§ion=store', { width: 460, height: 900 }); await page.waitForSelector('.wbs-card:not(.is-skeleton)', { timeout: 8000 }); await page.waitForTimeout(500); await shot(page, 'store-narrow'); await page.close(); const offline = await open('?panel§ion=store&offline', { width: 1180, height: 700 }); await offline.waitForSelector('.bt-empty', { timeout: 8000 }); check('store offline: explained', (await offline.locator('.bt-empty').innerText()).includes('not reachable')); await shot(offline, 'store-offline'); await offline.close(); } await browser.close(); console.log(failures.length ? `\n${failures.length} failure(s):\n- ${failures.join('\n- ')}` : '\nall checks passed'); process.exit(failures.length ? 1 : 0);