277 lines
15 KiB
JavaScript
277 lines
15 KiB
JavaScript
// 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());
|
|
});
|