// Real browser interaction against an owned read-only loopback fixture. All download // responses are synthetic; no production configuration or hotel service is used. const {test, before, after} = require('node:test'); const assert = require('node:assert/strict'); const {spawn} = require('node:child_process'); const path = require('node:path'); const {once} = require('node:events'); const {chromium} = require(process.env.ARR_TEST_PLAYWRIGHT_MODULE || 'playwright'); const root = path.resolve(__dirname, '../..'); let fixture, browser, base; before(async () => { fixture = spawn(process.env.ARR_TEST_PYTHON || path.join(root, '.venv/bin/python'), ['-u', '-c', ` from http.server import ThreadingHTTPServer from arr_web.preview import ReadOnlyPreview from arr_web.server import handler_for server = ThreadingHTTPServer(('127.0.0.1', 0), handler_for(ReadOnlyPreview())) print(server.server_port, flush=True) server.serve_forever() `], {cwd: root, stdio: ['ignore', 'pipe', 'ignore']}); const output = await Promise.race([ once(fixture.stdout, 'data').then(([chunk]) => chunk.toString().trim()), once(fixture, 'exit').then(() => { throw new Error('read-only fixture stopped'); }), ]); assert.match(output, /^\d+$/); base = `http://127.0.0.1:${output}`; browser = await chromium.launch({headless: true, ...(process.env.ARR_TEST_CHROMIUM ? {executablePath: process.env.ARR_TEST_CHROMIUM} : {})}); }); after(async () => { if (browser) await browser.close(); if (fixture && fixture.exitCode === null) { fixture.kill(); await once(fixture, 'exit'); } }); const config = (extra={}) => ({context_id:'synthetic-browser',ready:true, default_date:'2026-09-15', latest_task:null, ...extra}); const fulfill = (route, data) => route.fulfill({json:{ok:true, api_version:'1', data}}); async function setup(t, handler, saved=null, beforeGoto=null) { const context = await browser.newContext(); t.after(() => context.close()); if (saved) await context.addInitScript(saved => localStorage.setItem('arr:last-download:v2:synthetic-browser:operator', JSON.stringify(saved)), saved); const page = await context.newPage(); const errors = []; page.on('pageerror', error => errors.push(error.message)); t.after(() => assert.deepEqual(errors, [])); await page.clock.install(); await page.route('**/api/arr-downloads**', handler); if (beforeGoto) await beforeGoto(page); await page.goto(base); return page; } test('initial config failure recovers without reload or automatic POST', async t => { let reads=0, writes=0; const page = await setup(t, route => { if (route.request().method() !== 'GET') { writes++; return route.abort(); } return ++reads === 1 ? route.abort('failed') : fulfill(route, config()); }); await page.waitForFunction(() => document.querySelector('#arr-download-status').textContent.includes('重新连接')); assert(await page.locator('#arr-download-button').isDisabled()); await page.clock.runFor(15000); await page.waitForFunction(() => !document.querySelector('#arr-download-button').disabled); assert(reads >= 2, 'readiness must be retried after initial GET failure'); assert.equal(writes, 0); }); test('a date chosen while disconnected survives readiness recovery', async t => { let reads=0, writes=0; const page = await setup(t, route => { if (route.request().method() !== 'GET') { writes++; return route.abort(); } return ++reads === 1 ? route.abort('failed') : fulfill(route, config()); }); await page.waitForFunction(() => document.querySelector('#arr-download-status').textContent.includes('重新连接')); await page.fill('#arr-download-date', '2026-08-31'); await page.locator('#arr-download-date').blur(); await page.clock.runFor(11000); await page.waitForFunction(() => !document.querySelector('#arr-download-button').disabled); assert.equal(await page.inputValue('#arr-download-date'), '2026-08-31'); assert.equal(writes, 0); }); test('unavailable service becomes ready through GET-only polling', async t => { let reads=0, writes=0; const page = await setup(t, route => { if (route.request().method() !== 'GET') { writes++; return route.abort(); } return fulfill(route, config({ready:++reads > 1})); }); await page.waitForFunction(() => document.querySelector('#arr-download-status').textContent.includes('暂未就绪')); await page.clock.runFor(11000); await page.waitForFunction(() => !document.querySelector('#arr-download-button').disabled); assert.equal(reads, 2); assert.equal(writes, 0); }); const savedIntent = {request_id:'a'.repeat(32), report_date:'2026-09-10'}; const interrupted = {...savedIntent, from_date:savedIntent.report_date, to_date:savedIntent.report_date, status:'interrupted', job_id:null, can_retry:true}; test('initial disconnect preserves the saved task and ignores a different latest task', async t => { let configs=0, writes=0; const queried=[]; const page = await setup(t, route => { const request=route.request(); if (request.method() !== 'GET') { writes++; return route.abort(); } if (new URL(request.url()).pathname === '/api/arr-downloads') { return ++configs === 1 ? route.abort('failed') : fulfill(route, config({ latest_task:{...interrupted, request_id:'b'.repeat(32), report_date:'2026-09-14'}, })); } queried.push(request.url().split('/').at(-1)); return fulfill(route, interrupted); }, savedIntent); await page.waitForFunction(() => document.querySelector('#arr-download-status').textContent.includes('重新连接')); assert(await page.locator('#arr-download-date').isEnabled()); await page.clock.runFor(11000); await page.waitForFunction(() => document.querySelector('#arr-download-status').textContent.includes('任务中断')); assert.equal(await page.inputValue('#arr-download-date'), savedIntent.report_date); assert.deepEqual(queried, [savedIntent.request_id]); assert.equal(writes, 0); const retained=await page.evaluate(() => JSON.parse(localStorage.getItem('arr:last-download:v2:synthetic-browser:operator'))); assert.deepEqual(retained, savedIntent); }); test('task-status failure rechecks readiness and only an explicit click retries the original task', async t => { let configs=0, lookups=0; const writes=[]; const page = await setup(t, route => { const request=route.request(), pathname=new URL(request.url()).pathname; if (request.method() === 'POST') { writes.push({path:pathname, data:request.postDataJSON()}); return fulfill(route, interrupted); } if (pathname === '/api/arr-downloads') return fulfill(route, config({ready:++configs !== 2})); return ++lookups === 1 ? route.abort('failed') : fulfill(route, interrupted); }, savedIntent); await page.waitForFunction(() => !document.querySelector('#arr-download-button').disabled); await page.clock.runFor(11000); await page.waitForFunction(() => document.querySelector('#arr-download-status').textContent.includes('暂未就绪')); assert.equal(configs, 2); assert(await page.locator('#arr-download-button').isDisabled()); await page.clock.runFor(11000); await page.waitForFunction(() => !document.querySelector('#arr-download-button').disabled); assert.equal(writes.length, 0); await page.click('#arr-download-button'); assert.deepEqual(writes, [{path:`/api/arr-downloads/${savedIntent.request_id}/retry`,data:{}}]); assert.equal(await page.inputValue('#arr-download-date'), savedIntent.report_date); }); test('online events coalesce an in-flight config request and preserve the edited date', async t => { let configs=0, release; const page = await setup(t, route => { assert.equal(route.request().method(), 'GET'); configs++; if (configs === 2) return new Promise(resolve => { release=async () => { await fulfill(route, config()); resolve(); }; }); return fulfill(route, config()); }); await page.waitForFunction(() => !document.querySelector('#arr-download-button').disabled); await page.fill('#arr-download-date', '2026-08-30'); await page.locator('#arr-download-date').blur(); await page.evaluate(() => { window.dispatchEvent(new Event('online')); window.dispatchEvent(new Event('online')); }); // Synchronize on the intercepted request using the real Node event loop. for (let i=0; !release && i<50; i++) await new Promise(resolve => setTimeout(resolve,20)); assert(release); await page.evaluate(() => window.dispatchEvent(new Event('online'))); assert.equal(configs, 2); await release(); await page.waitForFunction(() => !document.querySelector('#arr-download-button').disabled); assert.equal(await page.inputValue('#arr-download-date'), '2026-08-30'); }); test('hidden pages pause retries and visibility immediately rechecks the service', async t => { let configs=0; const page = await setup(t, route => { configs++; return fulfill(route, config({ready:configs > 1})); }); await page.waitForFunction(() => document.querySelector('#arr-download-status').textContent.includes('暂未就绪')); await page.evaluate(() => { Object.defineProperty(document, 'hidden', {configurable:true, get:() => true}); document.dispatchEvent(new Event('visibilitychange')); }); await page.clock.runFor(30000); assert.equal(configs, 1); await page.evaluate(() => { Object.defineProperty(document, 'hidden', {configurable:true, get:() => false}); document.dispatchEvent(new Event('visibilitychange')); }); await page.waitForFunction(() => !document.querySelector('#arr-download-button').disabled); assert.equal(configs, 2); }); test('a late initial config does not overwrite a date being edited before blur', async t => { let release; const page = await setup(t, route => new Promise(resolve => { release=async () => { await fulfill(route, config()); resolve(); }; })); for (let i=0; !release && i<50; i++) await new Promise(resolve => setTimeout(resolve,20)); assert(release); await page.fill('#arr-download-date', '2026-08-29'); await release(); await page.waitForFunction(() => !document.querySelector('#arr-download-button').disabled); assert.equal(await page.inputValue('#arr-download-date'), '2026-08-29'); }); test('online before session initialization cannot consume another storage key or issue config calls', async t => { let reads=0, release; const page = await setup(t, route => { reads++; return fulfill(route, config()); }, null, page => page.route('**/api/session', route => new Promise(resolve => { release=async () => { await route.continue(); resolve(); }; }))); for (let i=0; !release && i<50; i++) await new Promise(resolve => setTimeout(resolve,20)); assert(release); await page.evaluate(() => window.dispatchEvent(new Event('online'))); await page.clock.runFor(15000); assert.equal(reads, 0); await release(); await page.waitForFunction(() => !document.querySelector('#arr-download-button').disabled); assert.equal(reads, 1); }); test('legacy and another instance cannot restore a terminal task into the default date', async t => { const queried=[]; const old={...interrupted,status:'failed'}; const page=await setup(t, route=>{ queried.push(new URL(route.request().url()).pathname); return fulfill(route,config({default_date:'2026-10-07',latest_task:old})); },null,page=>page.addInitScript(old=>{ localStorage.setItem('arr:last-download:operator',JSON.stringify(old)); localStorage.setItem('arr:last-download:v2:sandbox:operator',JSON.stringify(old)); },old)); await page.waitForFunction(()=>!document.querySelector('#arr-download-button').disabled); assert.equal(await page.inputValue('#arr-download-date'),'2026-10-07'); assert.deepEqual(queried,['/api/arr-downloads']); }); test('field review saves each explicit value and reconciles a lost finalize response on the same request', async t => { const task={...savedIntent,report_date:'2026-10-07',status:'needs_data_review',job_id:null,can_retry:false}; const items=[ {item_id:'1:ADULTS',source_sequence:1,confirmation_no:'SYNTHETIC-001',room_no:'SIM-001',company_name:'Synthetic Company',rate_code:'SYNTHETIC',field:'ADULTS',field_label:'成人数',reason_code:'MISSING',can_be_empty:false,value:null,confirmed:false}, {item_id:'1:PRODUCTS',source_sequence:1,confirmation_no:'SYNTHETIC-001',room_no:'SIM-001',company_name:'Synthetic Company',rate_code:'SYNTHETIC',field:'PRODUCTS',field_label:'包价代码',reason_code:'AMBIGUOUS',can_be_empty:true,value:['GUESSED'],confirmed:false}, ]; let revision=1, finalized=false; const writes=[]; const snapshot=()=>({request_id:task.request_id,report_date:task.report_date,status:'editing',revision,items,pending_count:items.filter(item=>!item.confirmed).length,total_count:items.length,can_finalize:items.every(item=>item.confirmed)}); const page=await setup(t,route=>{ const request=route.request(), pathname=new URL(request.url()).pathname; if(request.method()==='POST') { const body=request.postDataJSON(); writes.push({path:pathname,body}); assert.equal(body.revision,revision); if(pathname.endsWith('/finalize')) {finalized=true;return route.abort('failed');} const id=decodeURIComponent(pathname.split('/').at(-1)); const item=items.find(item=>item.item_id===id); assert(item,'the item URL must identify the source field'); item.value=body.value; item.confirmed=true; revision++; return fulfill(route,{revision}); } if(pathname==='/api/arr-downloads') return fulfill(route,config({latest_task:task})); if(pathname.endsWith('/data-review')) return fulfill(route,snapshot()); return fulfill(route,finalized ? {...task,status:'processing'} : task); }); await page.locator('#arr-data-review-panel').waitFor({state:'visible'}); assert(await page.locator('#arr-download-data-review').isVisible()); assert(await page.locator('#arr-data-review-finalize').isDisabled()); const adults=page.locator('[data-arr-data-review-item-id="1:ADULTS"]'); await adults.locator('input').fill('0'); await adults.locator('[data-arr-data-review-save]').click(); await page.waitForFunction(()=>document.querySelectorAll('#arr-data-review-body .is-complete').length===1); assert(await page.locator('#arr-data-review-finalize').isDisabled()); const products=page.locator('[data-arr-data-review-item-id="1:PRODUCTS"]'); assert.equal(await products.locator('textarea').inputValue(),'','ambiguous values must not be guessed'); await products.locator('[data-arr-data-review-empty]').click(); await page.waitForFunction(()=>!document.querySelector('#arr-data-review-finalize').disabled); await page.locator('#arr-data-review-finalize').click(); await page.waitForFunction(()=>document.querySelector('#arr-download-status').textContent.includes('正在处理并校验')); assert.equal(writes.length,3); assert.deepEqual(writes.map(write=>write.body),[{revision:1,value:'0'},{revision:2,value:[]},{revision:3}]); assert(writes.every(write=>write.path.startsWith(`/api/arr-downloads/${task.request_id}/data-review/`))); assert(await page.locator('#arr-data-review-panel').isHidden()); });