// check-launcher.mjs — App 啟動器的**看得見的驗收**(inkstone/arcrun-rag#137)
//
// 🔴 leo 2026-08-04:「既然是 Web,你做的時候**無法檢視**?」
// ⇒ 這支就是那句話對 App 啟動器的兌現:真的把 frontend/dist 渲染出來、
// 真的按下去、真的量畫面上有什麼,而不是「看程式碼覺得應該會動」。
//
// 與旁邊 check-render.sh 的分工:
// · check-render.sh 量 CIS 的像素(lockup 接合/深色模式)——只跑得動 macOS 的 Chrome
// · 本檔 驗 App 啟動器的**行為**(九宮格/點進去/sandbox 橋/既有頁沒壞)
// 用 playwright 的 chromium,macOS/Linux/CI 都跑得動
//
// 跑法:`node check-launcher.mjs`(沒裝 playwright 就自己跳過,不擋別人的流程)
// 產出:截圖在 /tmp/arcrun-launcher-shots/,每一條檢查印一行 ✅/❌
//
// ⚠️ 這支驗的是**前端這一半**(畫面、互動、sandbox 邊界)。後端那一半
// (真的去問實例、session/API key 兩條路、白名單由實例裁決)在 apps_test.go,
// 那裡打的是照上游原始碼寫的假實例。**兩邊都綠才算前後端一體。**
import http from 'node:http';
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const here = path.dirname(fileURLToPath(import.meta.url));
const dist = path.join(here, 'frontend', 'dist');
const shots = '/tmp/arcrun-launcher-shots';
// playwright 從哪裡來:本地 node_modules 優先,其次全域安裝(CI/雲端常見)。
// CommonJS 包從 ESM import 進來時,具名匯出可能只在 default 上,兩種都接。
async function loadChromium() {
const tries = ['playwright', '/opt/node22/lib/node_modules/playwright/index.js'];
for (const spec of tries) {
try {
const m = await import(spec);
const c = m.chromium || (m.default && m.default.chromium);
if (c) return c;
} catch { /* 下一個 */ }
}
return null;
}
const chromium = await loadChromium();
if (!chromium) {
console.log('⚠️ 沒有 playwright,跳過畫面驗收(npm i -D playwright && npx playwright install chromium)');
process.exit(0);
}
if (!fs.existsSync(path.join(dist, 'index.html'))) {
console.error('❌ 沒有 frontend/dist——先 npm --prefix frontend run build');
process.exit(1);
}
// ── 靜態伺服器(dist 原樣,不改任何一個 byte)──
const MIME = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.png': 'image/png' };
const srv = http.createServer((req, res) => {
const rel = decodeURIComponent(req.url.split('?')[0]);
const f = path.join(dist, rel === '/' ? 'index.html' : rel);
if (!f.startsWith(dist) || !fs.existsSync(f) || fs.statSync(f).isDirectory()) { res.writeHead(404).end(); return; }
res.writeHead(200, { 'Content-Type': MIME[path.extname(f)] || 'application/octet-stream' });
fs.createReadStream(f).pipe(res);
});
await new Promise((r) => srv.listen(0, '127.0.0.1', r));
const origin = `http://127.0.0.1:${srv.address().port}`;
// ── 假後端:形狀逐欄對齊 apps.go 的 UIAppList/UIAppDetail ──
//
// 🔴 這裡**不是**在假裝 Go 那半會怎麼運作(那由 apps_test.go 打真 HTTP 驗)。
// 這裡只回「Go 已經回過來的東西長什麼樣」,讓畫面有東西可畫。
const mock = (scenario) => `
const scenario = ${JSON.stringify(scenario)};
window.__scenario = scenario;
window.__calls = [];
const APPS = [
{ id: 'note', name: '筆記', icon: '🗒️', hasUi: true, version: '0.1.0' },
{ id: 'weekly', name: '週報', icon: '📊', hasUi: false, version: '0.2.0' },
];
const DETAIL = {
note: { id:'note', name:'筆記', icon:'🗒️', version:'0.1.0', hasUi:true,
uiHtml: '
河道
' +
'document.getElementById("go").onclick=async()=>{' +
'const r=await window.arcrunApp.action("notes-create",{text:"嗨"});' +
'document.getElementById("out").textContent=JSON.stringify(r);};' +
'document.title="app-ui-loaded";' +
'window.__peek=(()=>{try{return typeof parent.go}catch(e){return "BLOCKED:"+e.name}})();' +
'',
workflows:[{name:'notes-create',description:'寫入一則筆記'}], actions:['notes-create'] },
weekly: { id:'weekly', name:'週報', icon:'📊', version:'0.2.0', hasUi:false, uiHtml:'',
workflows:[{name:'weekly-report',description:'每週一產週報'}], actions:['weekly-report'] },
};
const ACCOUNTS = [{
name:'我的知識庫', host:'arcrun-cypher-executor.demo.workers.dev', folders:[{path:'/Users/leo/Notes',accIdx:0}],
cloudVerKnown:true, cloudVerStale:false, cloudVerMine:'1.4.42', email:'leo@example.com',
}];
window.go = { main: { App: {
GetState: async () => ({
version:'v0.18.34', statusBig:'看守中 · 資料夾有變動就會自動整理', statusSub:'上次檢查 10:21',
syncing:false, engine:'workers-ai', geminiKey:'',
accounts: scenario === 'noaccount' ? [] : ACCOUNTS,
steps:[{title:'看守資料夾',meta:'有變動就自動開始',state:'done'},
{title:'發現變化',meta:'上次 10:20',state:'done'},
{title:'用 AI 整理成知識卡',state:'done'},
{title:'上傳到你的知識庫',meta:'上次 3 份',state:'done'}],
skipped:null, engineTrouble:false, logFolder:'/Users/leo/.arcrun-rag',
progress:{total:120,done:117,pending:3,cantSync:0,groups:[]}, quota:null,
}),
ListApps: async (i) => {
window.__calls.push(['ListApps', i]);
if (scenario === 'listerror') return { accIdx:i, account:'我的知識庫', apps:[], error:'連不上這個知識庫——請確認網路正常' };
if (scenario === 'noapps') return { accIdx:i, account:'我的知識庫', apps:[], error:'', source:'session' };
return { accIdx:i, account:'我的知識庫', host:'demo', apps:APPS, error:'', source:'session' };
},
GetApp: async (i, id) => {
window.__calls.push(['GetApp', i, id]);
if (scenario === 'needlogin') return { id, name:'筆記', icon:'🗒️', needsLogin:true, email:'leo@example.com', workflows:[], actions:[] };
return DETAIL[id];
},
RunAppAction: async (i, id, action, payload) => {
window.__calls.push(['RunAppAction', i, id, action, payload]);
if (action !== 'notes-create' && action !== 'weekly-report') throw new Error('這個動作不在這個 App 的白名單內');
return JSON.stringify({ ok:true, result:{ id:'blk_1', echo: JSON.parse(payload||'{}') } });
},
PortalLogin: async (i, pw) => { window.__calls.push(['PortalLogin', i]); if (pw !== 'pw') throw new Error('email 或密碼錯誤'); },
SyncNow: async () => {}, PickFolder: async () => '', AddFolder: async () => {}, RemoveFolder: async () => {},
SetAI: async () => {}, OpenURL: () => { window.__calls.push(['OpenURL']); }, OpenLogFolder: async () => {},
Connect: async () => {}, CheckUpdate: async () => ({ latest:'v0.18.34' }),
DownloadUpdate: async () => ({}), ApplyUpdate: async () => {}, ExportDiagnostics: async () => '',
}}};
`;
let fail = 0;
const ok = (m) => console.log(' ✅ ' + m);
const ng = (m) => { console.log(' ❌ ' + m); fail = 1; };
const check = (cond, m) => (cond ? ok(m) : ng(m));
fs.mkdirSync(shots, { recursive: true });
const browser = await chromium.launch();
async function open(scenario, theme = 'light') {
const ctx = await browser.newContext({ viewport: { width: 1100, height: 760 } });
const page = await ctx.newPage();
await page.addInitScript(`try{localStorage.setItem('arcrun_app_theme','${theme}')}catch(e){}`);
await page.addInitScript(mock(scenario));
const errs = [];
page.on('pageerror', (e) => errs.push(String(e)));
await page.goto(origin);
await page.waitForTimeout(400);
return { ctx, page, errs };
}
console.log('━━━ App 啟動器畫面驗收 ━━━');
// ① 打開就是九宮格,磁磚是真資料(不是寫死的)
{
const { ctx, page, errs } = await open('ok');
await page.waitForSelector('.appgrid', { timeout: 5000 }).catch(() => {});
const tiles = await page.$$eval('.appcell .nm', (n) => n.map((e) => e.textContent.trim()));
check(errs.length === 0, `啟動器沒有 JS 例外${errs.length ? '(' + errs[0] + ')' : ''}`);
check(await page.$('.appgrid') !== null, '打開就看到九宮格');
check(tiles.join('|') === '筆記|週報|加裝 App', `磁磚=實例回的那兩個 App + 加裝(實際:${tiles.join('|')})`);
check((await page.$eval('.apphead .s', (e) => e.textContent)).includes('2 個 App 已安裝'), '頁首說得出裝了幾個');
const calls = await page.evaluate(() => window.__calls.filter((c) => c[0] === 'ListApps').length);
check(calls === 1, `只問了實例一次,不是每秒輪詢(實際 ${calls} 次)`);
await page.waitForTimeout(2500);
const calls2 = await page.evaluate(() => window.__calls.filter((c) => c[0] === 'ListApps').length);
check(calls2 === 1, `2.5 秒後仍然只問過一次(實際 ${calls2} 次)— 沒有掛在每秒的 tick 上`);
await page.screenshot({ path: `${shots}/01-launcher-light.png` });
await ctx.close();
}
// ①b 深色模式也要對
{
const { ctx, page } = await open('ok', 'dark');
await page.waitForSelector('.appgrid');
await page.screenshot({ path: `${shots}/02-launcher-dark.png` });
check(await page.getAttribute('html', 'data-theme') === 'dark', '深色模式渲染得出來');
await ctx.close();
}
// ② 點一個「有自帶畫面」的 App → 進 sandbox iframe,且橋真的通
{
const { ctx, page, errs } = await open('ok');
await page.waitForSelector('.appgrid');
await page.click('[data-appopen="note"]');
await page.waitForSelector('#appFrame', { timeout: 5000 });
const frame = await (await page.$('#appFrame')).contentFrame();
await frame.waitForSelector('#go', { timeout: 5000 });
check(true, '點「筆記」進得去,App 自帶畫面渲染出來');
// 🔴 sandbox 邊界:iframe 裡的 script 不准碰得到 parent 的 window.go
// (那上面掛著 RemoveFolder(takedown)/SetAI/Connect)
const peek = await frame.evaluate(() => window.__peek);
check(typeof peek === 'string' && peek.startsWith('BLOCKED'), `iframe 碰不到 parent 的 window.go(實際:${peek})`);
// 橋:iframe 按鈕 → postMessage → go.RunAppAction → 回到 iframe
await frame.click('#go');
await frame.waitForFunction(() => document.getElementById('out').textContent.length > 0, null, { timeout: 5000 });
const out = await frame.$eval('#out', (e) => e.textContent);
check(out.includes('blk_1'), `sandbox 橋走得通,結果回得到 App 畫面(實際:${out.slice(0, 80)})`);
const call = await page.evaluate(() => window.__calls.find((c) => c[0] === 'RunAppAction'));
check(call && call[3] === 'notes-create' && JSON.parse(call[4]).text === '嗨', 'payload 原樣送到後端');
check(errs.length === 0, `App 頁沒有 JS 例外${errs.length ? '(' + errs[0] + ')' : ''}`);
await page.screenshot({ path: `${shots}/03-app-ui.png` });
await ctx.close();
}
// ②b 沒有自帶畫面的 App → 工作流清單 +「現在執行」
{
const { ctx, page } = await open('ok');
await page.waitForSelector('.appgrid');
await page.click('[data-appopen="weekly"]');
await page.waitForSelector('.wfitem', { timeout: 5000 });
check(await page.$eval('.wfitem .nm', (e) => e.textContent) === 'weekly-report', '沒有畫面的 App 列出它的工作流');
await page.click('[data-apprun="weekly-report"]');
await page.waitForFunction(() => document.querySelector('.wfitem .out').textContent.includes('完成'), null, { timeout: 5000 });
check(true, '「現在執行」按得動,結果顯示在那一列底下');
await page.screenshot({ path: `${shots}/04-app-workflows.png` });
await ctx.close();
}
// ③ 三種「不是正常」的狀態都要說人話,且分得出來
{
const { ctx, page } = await open('noapps');
await page.waitForSelector('.appgrid');
const txt = await page.textContent('#page');
check(txt.includes('這個知識庫還沒有 App'), '一個都沒裝 → 說「還沒有 App」並教怎麼裝');
check(!txt.includes('連不上'), '一個都沒裝時不准講成連線失敗');
await page.screenshot({ path: `${shots}/05-empty.png` });
await ctx.close();
}
{
const { ctx, page } = await open('listerror');
await page.waitForTimeout(300);
const txt = await page.textContent('#page');
check(txt.includes('暫時看不到') && txt.includes('連不上'), '問不到 → 說「問不到」並帶實例的原話');
check(!txt.includes('還沒有 App'), '問不到時不准講成「一個都沒裝」');
await page.screenshot({ path: `${shots}/06-error.png` });
await ctx.close();
}
{
const { ctx, page } = await open('noaccount');
await page.waitForTimeout(300);
const txt = await page.textContent('#page');
check(txt.includes('歡迎使用 Arcrun') || txt.includes('還沒有連上知識庫'), '沒連任何實例 → 說人話(連線精靈),不是空白');
await page.screenshot({ path: `${shots}/07-noaccount.png` });
await ctx.close();
}
{
const { ctx, page } = await open('needlogin');
await page.waitForSelector('.appgrid');
await page.click('[data-appopen="note"]');
await page.waitForSelector('#apPw', { timeout: 5000 });
check(await page.inputValue('#apEmail') === 'leo@example.com', 'session 過期 → 長登入框並預填帳號');
await page.screenshot({ path: `${shots}/08-needlogin.png` });
await ctx.close();
}
// ④ 驗收條件 3:現有功能一項都沒少
{
const { ctx, page, errs } = await open('ok');
await page.waitForSelector('.appgrid');
const navs = await page.$$eval('#side .nav .nm', (n) => n.map((e) => e.textContent.trim()));
for (const want of ['App 界面', '首頁', '我的知識庫', 'AI 設定', '版本與更新']) {
check(navs.includes(want), `側欄仍有「${want}」`);
}
const pages = [
['home', '現在的狀態'],
['lib:0', '開啟知識庫網頁'],
['ai', '用哪個 AI 幫你整理文件'],
['update', '版本與更新'],
];
for (const [p, marker] of pages) {
await page.click(`#side .nav[data-p="${p}"]`);
await page.waitForTimeout(200);
const t = await page.textContent('#page');
check(t.includes(marker), `「${p}」頁還在(找得到「${marker}」)`);
}
// 全站頁首的同步狀態與「立刻同步」在每一頁都看得到(首頁換走了它們也沒消失)
check((await page.textContent('#statusBig')).includes('看守中'), '同步狀態在全站頁首,換頁不會不見');
check(await page.$('#btnSync') !== null, '「立刻同步」按鈕還在');
check(errs.length === 0, `逛完全部頁面沒有 JS 例外${errs.length ? '(' + errs[0] + ')' : ''}`);
await page.screenshot({ path: `${shots}/09-existing-update.png` });
await ctx.close();
}
await browser.close();
srv.close();
console.log(`\n截圖:${shots}`);
console.log(fail ? '\n❌ 畫面驗收未過' : '\n✅ 畫面驗收全過');
process.exit(fail);