← Files Device ItARCHIVED FILE

scripts/verify.mjs

5.98 KB · Nov 5, 2026 · 12:10 UTC

↓ Download file

#!/usr/bin/env node
import fs from 'node:fs/promises';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { chromium } from 'playwright';
import { localURL } from './qr.mjs';
import { options, required } from './options.mjs';

export async function verify(args) {
  const url = localURL(required(args, 'url'));
  if (!['localhost', '127.0.0.1'].includes(url.hostname) || url.pathname !== '/') throw new Error('Offline verification requires a root-mounted loopback URL on this computer');
  const expected = required(args, 'expect-text').trim();
  if (!expected) throw new Error('Expected visible text must not be empty');
  for (const file of [args.report, args.screenshot].filter(Boolean)) {
    try { await fs.lstat(file); throw new Error(`Evidence path already exists: ${file}`); } catch (e) { if (e.code !== 'ENOENT') throw e; }
  }
  const results = [];
  const check = (name, ok, detail = '') => { results.push({ name, ok: Boolean(ok), detail }); };
  const report = { url: url.href, ok: false, offline: 'not verified', browserStarted: false, browserClosed: false, results };
  let browser;
  async function get(file) {
    const target = new URL(file, url);
    if (target.origin !== url.origin || target.search) throw new Error('Unexpected non-local asset URL');
    const response = await fetch(target, { redirect: 'error', signal: AbortSignal.timeout(10000) });
    if (!response.ok) throw new Error(`${file}: HTTP ${response.status}`);
    return response;
  }
  try {
    const manifest = await (await get('manifest.webmanifest')).json();
    const inventory = await (await get('device-it-build.json')).json();
    check('manifest has local standalone scope', manifest.display === 'standalone' && manifest.start_url === './' && manifest.scope === './');
    const icons = [...manifest.icons, { src: './device-it/apple-touch-icon-180.png', sizes: '180x180' }];
    check('manifest includes 192, 512 and maskable icons', manifest.icons.some(x => x.sizes === '192x192') && manifest.icons.some(x => x.sizes === '512x512' && x.purpose === 'maskable'));
    for (const icon of icons) {
      const bytes = Buffer.from(await (await get(icon.src)).arrayBuffer());
      const [width, height] = icon.sizes.split('x').map(Number);
      check(`PNG icon ${icon.sizes}${icon.purpose ? ' ' + icon.purpose : ''}`, bytes.length > 24 && bytes.subarray(0, 8).equals(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10])) && bytes.readUInt32BE(16) === width && bytes.readUInt32BE(20) === height);
    }
    browser = await chromium.launch({ headless: true });
    report.browserStarted = true;
    report.browserVersion = browser.version();
    const context = await browser.newContext();
    // Verification visits only this local origin, in a disposable browser profile.
    await context.route('**/*', route => new URL(route.request().url()).origin === url.origin ? route.continue() : route.abort());
    const page = await context.newPage();
    const errors = [];
    page.on('pageerror', error => errors.push(error.message));
    await page.goto(url.href, { waitUntil: 'load', timeout: 15000 });
    check('expected online shell is visible', (await page.locator('body').innerText()).includes(expected));
    await page.waitForFunction(() => navigator.serviceWorker?.controller !== null && navigator.serviceWorker?.controller !== undefined, null, { timeout: 15000 });
    const state = await page.evaluate(async () => {
      const registration = await navigator.serviceWorker.ready;
      return { state: registration.active?.state, script: registration.active?.scriptURL, scope: registration.scope };
    });
    check('service worker activated for local root', state.state === 'activated' && state.scope === url.href && state.script === new URL('sw.js', url).href, state);
    const cached = await page.evaluate(async () => {
      const result = [];
      for (const name of await caches.keys()) {
        const cache = await caches.open(name);
        for (const request of await cache.keys()) {
          const response = await cache.match(request);
          const hash = [...new Uint8Array(await crypto.subtle.digest('SHA-256', await response.arrayBuffer()))].map(x => x.toString(16).padStart(2, '0')).join('');
          result.push({ url: request.url, sha256: hash, cache: name });
        }
      }
      return result;
    });
    const expectedCache = inventory.precache.map(entry => ({ url: new URL(entry.file, url).href, sha256: entry.sha256 }));
    check('cache contains exactly approved static bytes', cached.length === expectedCache.length && expectedCache.every(entry => cached.some(actual => actual.url === entry.url && actual.sha256 === entry.sha256)));
    report.cachedAssets = cached;
    await context.setOffline(true);
    await page.reload({ waitUntil: 'load', timeout: 15000 });
    const offlineVisible = (await page.locator('body').innerText()).includes(expected);
    check('expected offline shell is visible after reload', offlineVisible);
    report.offline = offlineVisible ? 'verified shell' : 'failed';
    check('no uncaught page errors', errors.length === 0, errors);
    if (args.screenshot) await fs.writeFile(args.screenshot, await page.screenshot({ fullPage: true }), { flag: 'wx' });
  } catch (error) {
    check('verification completed', false, error.message);
  } finally {
    if (browser) {
      try { await browser.close(); report.browserClosed = true; }
      catch (error) { check('browser cleanup', false, error.message); }
    }
  }
  report.ok = report.browserStarted && report.browserClosed && results.every(x => x.ok);
  if (args.report) await fs.writeFile(args.report, JSON.stringify(report, null, 2) + '\n', { flag: 'wx' });
  return report;
}
if (process.argv[1] && path.resolve(process.argv[1]) === fileURLToPath(import.meta.url)) {
  try {
    const report = await verify(options(process.argv.slice(2), ['url', 'expect-text', 'report', 'screenshot']));
    console.log(JSON.stringify(report, null, 2));
    if (!report.ok) process.exitCode = 1;
  } catch (error) { console.error(`device-it verify: ${error.message}`); process.exitCode = 1; }
}

SHA-256: 66231849d235cb1b5e010107f28a06136cbbd5596da9ba84e5b03d0fedb49f5f