mirror of
https://github.com/vicliu624/trail-mate.git
synced 2026-10-23 21:43:28 +00:00
37 lines
8.2 KiB
JavaScript
37 lines
8.2 KiB
JavaScript
// Explicit hardware acceptance, never routine CI. Uses native navigator.serial
|
|
// and the actual device page. Attempts CDP DeviceAccess selection where exposed;
|
|
// --interactive lets the operator select the native browser chooser instead.
|
|
// A writer guard permits only HELLO and read-only management operations.
|
|
import http from 'node:http';import fs from 'node:fs/promises';import path from 'node:path';import assert from 'node:assert/strict';
|
|
import {build} from '../../site/node_modules/esbuild/lib/main.js';
|
|
import {chromium} from '../../site/node_modules/playwright/index.mjs';
|
|
const args=process.argv.slice(2),option=name=>args[args.indexOf(name)+1];
|
|
if(!args.includes('--port')||!/^COM\d+$/.test(option('--port')))throw Error('Use --port COMxx [--expect-config-sha SHA256] [--expect-author fingerprint]');
|
|
const port=option('--port'),root=path.resolve('site'),resultPath=path.resolve('.codex-build/hostlink-wio-l2/web-serial-read.json');
|
|
const observer=`import{FrameDecoder,FRAME,decodeHello}from'./site/device/src/hostlink.js';import{decodeManagement}from'./site/device/src/management-protocol.js';import{TmsDocument}from'./site/device/src/tms-document.js';
|
|
const hex=value=>Array.from(value,byte=>byte.toString(16).padStart(2,'0')).join(''),allowed=new Set([0,1,2,3,7,17]),pending=new Map();let snapshots=0,opened=0,closed=0,hello,capabilities,readError,output,outputHash,configSummary;
|
|
const operations=[],nativeOpen=SerialPort.prototype.open,nativeClose=SerialPort.prototype.close;
|
|
SerialPort.prototype.open=async function(...args){await nativeOpen.apply(this,args);opened++;const input=new FrameDecoder(),getReader=this.readable.getReader.bind(this.readable),getWriter=this.writable.getWriter.bind(this.writable);
|
|
this.readable.getReader=(...args)=>{const reader=getReader(...args),read=reader.read.bind(reader);reader.read=async(...args)=>{const result=await read(...args);if(result.value)for(const frame of input.push(result.value)){try{if(frame.type===FRAME.HELLO_ACK)hello=decodeHello(frame.payload);if(frame.type!==FRAME.MANAGEMENT_RESPONSE)continue;const reply=decodeManagement(frame.payload),op=pending.get(hex(reply.request));pending.delete(hex(reply.request));if(reply.code){readError='device status '+reply.code;continue;}if(op===0)capabilities=reply.body;if(op!==17)continue;const [offset,total,hash,bytes]=reply.body;if(offset===0){output=new Uint8Array(total);outputHash=hash.slice();}if(!output||offset+bytes.length>output.length)throw Error('Invalid snapshot offsets');output.set(bytes,offset);if(offset+bytes.length===output.length){const retained=output;output=undefined;const actual=hex(new Uint8Array(await crypto.subtle.digest('SHA-256',retained)));if(actual!==hex(outputHash))throw Error('Observed snapshot digest mismatch');configSummary={sha256:actual,fields:new TmsDocument(retained).size,bytes:retained.length};retained.fill(0);snapshots++;}}catch(error){readError=String(error);}}return result;};return reader;};
|
|
this.writable.getWriter=(...args)=>{const writer=getWriter(...args),write=writer.write.bind(writer);writer.write=(bytes)=>{const frames=new FrameDecoder().push(bytes);if(frames.length!==1)throw Error('Hardware probe rejected non-HostLink write');const frame=frames[0];if(frame.type===FRAME.MANAGEMENT_REQUEST){const request=decodeManagement(frame.payload),operation=request.code;if(!allowed.has(operation))throw Error('Hardware probe rejected modifying operation '+operation);operations.push(operation);pending.set(hex(request.request),operation);}else if(frame.type!==FRAME.HELLO)throw Error('Hardware probe rejected frame '+frame.type);return write(bytes);};return writer;};
|
|
};
|
|
SerialPort.prototype.close=async function(...args){const result=await nativeClose.apply(this,args);closed++;return result;};
|
|
globalThis.webSerialProbeSummary=()=>({nativeSerial:true,opened,closed,snapshots,hello,operations:operations.slice(),capabilityCount:capabilities?.[4]?.length,config:configSummary,readError:readError??null});`;
|
|
const hook=await build({stdin:{contents:observer,resolveDir:process.cwd()},bundle:true,write:false,format:'iife',platform:'browser'});
|
|
const server=http.createServer(async(req,res)=>{try{const name=decodeURIComponent(new URL(req.url,'http://localhost').pathname);if(!name.startsWith('/trail-mate/'))throw Error('prefix');const file=path.resolve(root,name.slice(12)+(name.endsWith('/')?'index.html':''));if(!file.startsWith(root+path.sep))throw Error('path');const bytes=await fs.readFile(file);res.setHeader('Content-Type',({'.html':'text/html','.js':'text/javascript','.css':'text/css','.json':'application/json','.png':'image/png','.svg':'image/svg+xml','.woff2':'font/woff2','.ttf':'font/ttf'})[path.extname(file)]??'application/octet-stream');res.end(bytes);}catch{res.writeHead(404).end();}});await new Promise(resolve=>server.listen(0,'127.0.0.1',resolve));
|
|
const interactive=args.includes('--interactive'),browser=await chromium.launch({channel:process.env.TRAIL_MATE_TEST_BROWSER_CHANNEL??'msedge',headless:!interactive}),errors=[],prompts=[];let page,chooserError;
|
|
try{
|
|
page=await browser.newPage();page.on('pageerror',error=>errors.push(String(error)));await page.addInitScript({content:hook.outputFiles[0].text});
|
|
const cdp=await page.context().newCDPSession(page);await cdp.send('DeviceAccess.enable');
|
|
cdp.on('DeviceAccess.deviceRequestPrompted',async event=>{try{prompts.push(event.devices.map(device=>device.name));const candidates=event.devices.filter(device=>new RegExp('\\b'+port+'\\b','i').test(device.name));if(candidates.length===0)return;if(candidates.length!==1){await cdp.send('DeviceAccess.cancelPrompt',{id:event.id});throw Error('Expected exactly one '+port+' chooser entry');}await cdp.send('DeviceAccess.selectPrompt',{id:event.id,deviceId:candidates[0].id});}catch(error){chooserError=String(error);}});
|
|
await page.goto(`http://127.0.0.1:${server.address().port}/trail-mate/device/?lang=zh-Hans`);assert.equal(await page.evaluate(()=>Boolean(navigator.serial)),true);
|
|
await page.getByRole('button',{name:'连接设备',exact:true}).click();if(interactive)console.log('Choose '+port+' in the browser serial dialog. This probe cannot modify configuration or identity.');await page.getByText('已验证配置快照,共 309 个字段。',{exact:true}).waitFor({timeout:interactive?300000:30000});await page.getByText('设备作者签名已验证。',{exact:true}).waitFor();
|
|
if(chooserError)throw Error(chooserError);if(args.includes('--expect-author'))await page.getByText(option('--expect-author'),{exact:true}).waitFor();
|
|
const first=await page.evaluate(()=>webSerialProbeSummary());assert.equal(first.snapshots,1);assert.equal(first.capabilityCount,56);assert.equal(first.readError,null);if(args.includes('--expect-config-sha'))assert.equal(first.config.sha256,option('--expect-config-sha'));
|
|
await page.getByRole('button',{name:'断开设备',exact:true}).click();await page.waitForFunction(()=>webSerialProbeSummary().closed===1);await page.getByText('设备已断开,作者授权已结束。',{exact:true}).waitFor();
|
|
await page.getByRole('button',{name:'连接设备',exact:true}).click();if(interactive)console.log('Reconnect: choose '+port+' again.');await page.waitForFunction(()=>webSerialProbeSummary().snapshots===2,{},{timeout:interactive?300000:30000});await page.getByText('设备作者签名已验证。',{exact:true}).waitFor();
|
|
await page.getByRole('button',{name:'断开设备',exact:true}).click();await page.waitForFunction(()=>webSerialProbeSummary().closed===2);
|
|
const result=await page.evaluate(()=>webSerialProbeSummary());assert.deepEqual(result.config,first.config);assert.equal(result.readError,null);assert.deepEqual(errors,[]);assert.ok(result.operations.every(op=>[0,1,2,3,7,17].includes(op)));await fs.writeFile(resultPath,JSON.stringify({passed:true,port,...result,configurationWrites:0,identityExports:0},null,2));console.log(JSON.stringify({passed:true,port,...result,configurationWrites:0,identityExports:0}));
|
|
}catch(error){const summary=page?await page.evaluate(()=>typeof webSerialProbeSummary==='function'?webSerialProbeSummary():null).catch(()=>null):null;await fs.writeFile(resultPath,JSON.stringify({passed:false,port,error:String(error),chooserError,prompts,summary,errors},null,2));console.error(JSON.stringify({chooserError,prompts,summary}));throw error;}
|
|
finally{await browser.close();await new Promise(resolve=>server.close(resolve));}
|