const fs=require('fs'), path=require('path'), assert=require('assert').strict;
const {firefox}=require(process.env.CODEX_PRIMARY_RUNTIME_NODE_MODULES ? process.env.CODEX_PRIMARY_RUNTIME_NODE_MODULES+'/playwright' : 'playwright');
const ROOT=path.resolve(__dirname,'..');
(async()=>{
const browser=await firefox.launch({headless:true,env:{...process.env,MOZ_DISABLE_CONTENT_SANDBOX:'1'}});
const context=await browser.newContext({viewport:{width:412,height:840},hasTouch:true});
const data={},messages=[],errors=[];
let sensor={available:true,active:true,x:0,rotation:0};
let holdNextSample=false, releaseSample=null;
await context.exposeFunction('readStore',async keys=>keys===null?data:Object.fromEntries((Array.isArray(keys)?keys:[keys]).filter(k=>k in data).map(k=>[k,data[k]])));
await context.exposeFunction('writeStore',async values=>Object.assign(data,values));
await context.exposeFunction('removeStore',async keys=>keys.forEach(k=>delete data[k]));
await context.exposeFunction('nativeMessage',async (_name,msg)=>{
messages.push(msg);
if(msg.type==='motion'&&msg.enabled&&holdNextSample){
holdNextSample=false;
return new Promise(resolve=>{releaseSample=resolve;});
}
return msg.type==='motion'?{...sensor}:null;
});
await context.addInitScript(()=>{window.browser={storage:{local:{get:window.readStore,set:window.writeStore,remove:window.removeStore}},runtime:{getURL:path=>'https://ncode.syosetu.com/__reader/'+path,sendNativeMessage:window.nativeMessage,onMessage:{addListener:fn=>{window.toggleReader=fn;}}}};});
const page=await context.newPage();page.on('pageerror',e=>errors.push(e.message));
const html='
縦書きのテスト
'+Array.from({length:100},(_,i)=>`
これは第${i+1}段落です。読書の時間を、静かに楽しみます。縦書きの列は右から左へ進みます。
`).join('')+'
';
await context.route('**/*',r=>{const url=r.request().url();if(url.startsWith('https://ncode.syosetu.com/__reader/langpacks/'))return r.fulfill({contentType:'application/json',body:fs.readFileSync(path.join(ROOT,'app/src/main/assets/reader/langpacks',url.split('/').pop()),'utf8')});return url==='https://ncode.syosetu.com/n0552ep/75/'?r.fulfill({contentType:'text/html',body:html}):r.abort();});
const script=path.join(ROOT,'app/src/main/assets/reader/content.js');
async function load(){await page.goto('https://ncode.syosetu.com/n0552ep/75/',{waitUntil:'domcontentloaded'});await page.addScriptTag({path:path.join(path.dirname(script),'i18n.js')});await page.addScriptTag({path:path.join(path.dirname(script),'motion.js')});await page.addScriptTag({path:script});await page.locator('#narou-tategaki-host #panel').waitFor({state:'visible'});await page.waitForTimeout(250);}
await load();
const host=page.locator('#narou-tategaki-host'),panel=host.locator('#panel'),v=host.locator('#viewport');
assert((await panel.getAttribute('class')).includes('chrome-hidden'));
assert.equal(await v.evaluate(e=>e.clientHeight),840);
assert.equal(await panel.locator('article p').evaluateAll(ps=>ps.filter(p=>!p.textContent.trim()&&!p.querySelector('img')).length),0);
assert(messages.some(m=>m.type==='reader'&&m.open));
assert(messages.some(m=>m.type==='theme'&&m.theme==='paper'));
const width=await v.evaluate(e=>e.scrollWidth);
const firstParagraph=panel.locator('article p').first();
// A tap on ruby text focuses its paragraph without opening chrome.
await firstParagraph.locator('rt').dispatchEvent('pointerdown',{clientX:100,clientY:100});
await firstParagraph.locator('rt').dispatchEvent('pointerup',{clientX:100,clientY:100});
assert.equal(await panel.locator('article p.reading-focus').count(),1);
assert.equal(await firstParagraph.getAttribute('aria-current'),'true');
assert((await panel.getAttribute('class')).includes('chrome-hidden'),'first paragraph tap only highlights');
assert.equal(await v.evaluate(e=>e.scrollWidth),width,'focus color must not reflow text');
await panel.locator('article p').nth(1).dispatchEvent('keydown',{key:'Enter'});
assert.equal(await panel.locator('article p').nth(1).getAttribute('aria-current'),'true','keyboard paragraph focus');
await page.touchscreen.tap(5,5);assert(!(await panel.getAttribute('class')).includes('chrome-hidden'));
await panel.locator('#reading-normalColor').evaluate(e=>{e.value='#8899aa';e.dispatchEvent(new Event('input'));});
await panel.locator('#reading-highlightColor').evaluate(e=>{e.value='#ff7755';e.dispatchEvent(new Event('input'));});
assert.equal(data.narouTategaki.normalColor,'#8899aa');
assert.equal(data.narouTategaki.highlightColor,'#ff7755');
assert.equal(await firstParagraph.evaluate(e=>getComputedStyle(e).color),'rgb(136, 153, 170)');
assert.equal(await panel.locator('article p.reading-focus').evaluate(e=>getComputedStyle(e).color),'rgb(255, 119, 85)');
assert.equal(await panel.locator('article p.reading-focus rt').evaluate(e=>getComputedStyle(e).color),'rgb(255, 119, 85)','ruby inherits highlight');
assert.equal(await v.evaluate(e=>e.scrollWidth),width,'controls must not reflow text');
await panel.locator('#paragraph').evaluate(e=>{e.value='1.2';e.dispatchEvent(new Event('input'));});
await page.waitForTimeout(100);assert.equal(data.narouTategaki.paragraph,1.2);
await panel.locator('select').first().selectOption('white');
assert(messages.some(m=>m.type==='theme'&&m.theme==='white'));
await panel.locator('select').first().selectOption('dark');
assert(messages.some(m=>m.type==='theme'&&m.theme==='dark'));
await panel.getByRole('button',{name:'App menu'}).click();assert(messages.some(m=>m.type==='menu'));
await panel.getByRole('button',{name:'Continue reading'}).click();
await v.focus();await page.keyboard.press('PageDown');await page.keyboard.press('PageDown');
await page.waitForTimeout(400);
const before=await v.evaluate(e=>-e.scrollLeft/(e.scrollWidth-e.clientWidth));assert(before>0);
assert(Math.abs(data['narouPosition:/n0552ep/75/'].position-before)<.001);
await load();
const restored=await v.evaluate(e=>-e.scrollLeft/(e.scrollWidth-e.clientWidth));
assert(Math.abs(before-restored)<.002,`${before} != ${restored}`);
assert.equal(await panel.getAttribute('data-theme'),'dark');
assert.equal(await panel.locator('#reading-highlightColor').inputValue(),'#ff7755','custom colors survive reload and theme change');
assert(messages.filter(m=>m.type==='theme'&&m.theme==='dark').length>=2,'restored theme must reach the native app');
await page.touchscreen.tap(5,5);
// Language switches update existing controls without losing focus, colors or position.
const language=panel.locator('#reading-language');
const positionBeforeLanguage=await v.evaluate(e=>e.scrollLeft);
await language.selectOption('ja');
await page.waitForFunction(()=>document.querySelector('#narou-tategaki-host').shadowRoot.querySelector('#panel').lang==='ja');
assert.equal(await panel.locator('#reading-reset-colors').textContent(),'文字色を戻す');
assert.equal(data.narouTategaki.language,'ja');
assert.equal(await panel.locator('#reading-highlightColor').inputValue(),'#ff7755');
assert.equal(await v.evaluate(e=>e.scrollLeft),positionBeforeLanguage);
assert(messages.some(m=>m.type==='language'&&m.language==='ja'));
await load();
assert.equal(await panel.getAttribute('lang'),'ja','language survives reload');
await page.touchscreen.tap(5,5);
await language.selectOption('en');
await page.waitForFunction(()=>document.querySelector('#narou-tategaki-host').shadowRoot.querySelector('#panel').lang==='en');
// Optional: inspect with a local screenshot here.
await panel.getByRole('button',{name:'Original page',exact:true}).click();assert(await panel.isHidden());
assert(messages.some(m=>m.type==='reader'&&!m.open));
await host.locator('#launch').click();await page.waitForTimeout(100);
assert(Math.abs(before-await v.evaluate(e=>-e.scrollLeft/(e.scrollWidth-e.clientWidth)))<.002);
await page.setViewportSize({width:840,height:412});await page.waitForTimeout(200);
assert(Math.abs(before-await v.evaluate(e=>-e.scrollLeft/(e.scrollWidth-e.clientWidth)))<.002);
assert.equal(await v.evaluate(e=>e.scrollHeight),await v.evaluate(e=>e.clientHeight));
// Optional: inspect landscape layout here.
// Motion uses mocked native accelerometer samples, real Gecko layout and animation.
await page.touchscreen.tap(5,5);
await panel.locator('#motion-toggle').click();
await page.waitForTimeout(250);
await panel.locator('#motion-neutral').click();
await panel.getByRole('button',{name:'Continue reading'}).click();
const motionStart=await v.evaluate(e=>e.scrollLeft);
sensor.x=.5;
await page.waitForTimeout(900);
assert(await v.evaluate(e=>e.scrollLeft){const r=e.getBoundingClientRect();return Math.max(r.left-innerWidth/2,innerWidth/2-r.right,0)<40;}),'automatic focus follows the screen center (including column gaps and update interval)');
sensor.x=0;await page.waitForTimeout(180);
// Keep a manually selected paragraph while braking, including residual movement.
await panel.locator('article p').last().dispatchEvent('keydown',{key:'Enter'});
await page.waitForTimeout(250);
assert.equal(await panel.locator('article p').last().getAttribute('aria-current'),'true','braking range suppresses automatic selection');
sensor.x=.5;await page.waitForTimeout(250);
assert.equal(await panel.locator('article p').last().getAttribute('aria-current'),null,'automatic focus resumes outside braking range');
// Opening controls brakes immediately.
await page.touchscreen.tap(5,5);
const stopped=await v.evaluate(e=>e.scrollLeft);
await page.waitForTimeout(350);
assert.equal(await v.evaluate(e=>e.scrollLeft),stopped,'controls must pause motion');
// Calibration uses the same live sensor and physics as reading, even with controls open.
const marker=panel.locator('#motion-marker'), band=panel.locator('#motion-band');
const preview=panel.locator('#motion-preview');
assert(await marker.isVisible(),'live tilt marker is visible while paused');
assert.equal(await marker.evaluate(e=>e.style.left),'75%');
assert.equal(await band.evaluate(e=>e.style.left),'46%');
assert.match(await panel.locator('#motion-boundaries').textContent(),/-0.08.*0.08/);
await preview.click();
await page.waitForTimeout(500);
assert(await v.evaluate(e=>e.scrollLeft)e.scrollLeft);
sensor.x=-.4;
await page.waitForTimeout(250);
assert.equal(await v.evaluate(e=>e.scrollLeft),previewStopped,'pause immediately stops preview');
assert.equal(await marker.evaluate(e=>e.style.left),'30%','sensor marker updates while paused');
await panel.locator('#motion-offset').evaluate(e=>{e.value='-0.4';e.dispatchEvent(new Event('input'));});
await panel.locator('#motion-deadzone').evaluate(e=>{e.value='0.12';e.dispatchEvent(new Event('input'));});
assert.match(await panel.locator('#motion-boundaries').textContent(),/-0.52.*-0.28/);
await preview.click();await page.waitForTimeout(250);
assert.equal(await v.evaluate(e=>e.scrollLeft),previewStopped,'shaded neutral range matches actual physics');
sensor.x=.5;await page.waitForTimeout(300);
await panel.locator('#motion-settings summary').click();
const collapsed=await v.evaluate(e=>e.scrollLeft);
await page.waitForTimeout(250);
assert.equal(await v.evaluate(e=>e.scrollLeft),collapsed,'collapsing calibration stops preview');
await panel.locator('#motion-settings summary').click();
assert.equal(await preview.getAttribute('aria-pressed'),'false');
await panel.locator('#motion-deadzone').evaluate(e=>{e.value='0.08';e.dispatchEvent(new Event('input'));});
if(process.env.CALIBRATION_SCREENSHOT){
await page.setViewportSize({width:412,height:840});await page.waitForTimeout(200);
await page.screenshot({path:process.env.CALIBRATION_SCREENSHOT});
await page.setViewportSize({width:840,height:412});await page.waitForTimeout(200);
}
sensor.x=0;
await page.waitForTimeout(200);
await panel.locator('#motion-neutral').click();
await panel.locator('#motion-reverse').check();
await panel.getByRole('button',{name:'Continue reading'}).click();
sensor.x=.5;
await page.waitForTimeout(800);
assert(await v.evaluate(e=>e.scrollLeft)>collapsed+15,'reverse must change direction');
sensor.active=false;
await page.waitForTimeout(250);
const inactive=await v.evaluate(e=>e.scrollLeft);
await page.waitForTimeout(300);
assert.equal(await v.evaluate(e=>e.scrollLeft),inactive,'inactive sensor must stop');
sensor.active=true;sensor.rotation=1;sensor.x=-.6;
await page.waitForTimeout(350);
assert.equal(await v.evaluate(e=>e.scrollLeft),inactive,'rotation must reset neutral');
await page.touchscreen.tap(5,5);
await panel.locator('#motion-toggle').click();
assert(messages.some(m=>m.type==='motion'&&!m.enabled));
await load();
assert.equal(await panel.locator('#motion-toggle').getAttribute('aria-pressed'),'false','disabled state persists');
assert.equal(data.narouMotion.reverse,true,'motion preferences persist');
await page.touchscreen.tap(5,5);
await panel.locator('#motion-toggle').click();
await page.waitForTimeout(150);
await load();
assert.equal(await panel.locator('#motion-toggle').getAttribute('aria-pressed'),'true','enabled state persists');
const calibrated=await v.evaluate(e=>e.scrollLeft);
await page.waitForTimeout(300);
assert.equal(await v.evaluate(e=>e.scrollLeft),calibrated,'restored enabled state calibrates without jumping');
// Model a cached chapter returning while its previous native request is pending.
holdNextSample=true;
await page.waitForTimeout(200);
assert(releaseSample,'a sensor request is pending before navigating away');
await v.dispatchEvent('pointerdown',{clientX:1,clientY:200});
await page.evaluate(()=>window.dispatchEvent(new PageTransitionEvent('pagehide',{persisted:true})));
const hiddenPolls=messages.filter(m=>m.type==='motion'&&m.enabled).length;
await page.waitForTimeout(250);
assert.equal(messages.filter(m=>m.type==='motion'&&m.enabled).length,hiddenPolls,'cached page must stop polling');
const returnStart=await v.evaluate(e=>e.scrollLeft);
sensor.x=-.1; // Neutral was -.6, reverse is enabled: scroll toward the start.
await page.evaluate(()=>window.dispatchEvent(new PageTransitionEvent('pageshow',{persisted:true})));
await page.waitForTimeout(600);
assert(await v.evaluate(e=>e.scrollLeft)>returnStart+10,'history restore resumes without waiting for the old reply or pointerup');
assert(messages.slice(-6).some(m=>m.type==='motion'&&m.enabled&&m.open),'poll carries reader state to repair a native navigation reset');
releaseSample({available:false});releaseSample=null;
await page.waitForTimeout(250);
assert.equal(await panel.locator('#motion-toggle').getAttribute('aria-pressed'),'true','late pre-navigation reply must not disable the restored reader');
// A lost reply without a lifecycle event also recovers through the timeout.
holdNextSample=true;
await page.waitForTimeout(200);
assert(releaseSample);
const stalledPolls=messages.filter(m=>m.type==='motion'&&m.enabled).length;
await page.waitForTimeout(1250);
assert(messages.filter(m=>m.type==='motion'&&m.enabled).length>stalledPolls,'lost native reply must not permanently block polling');
releaseSample({available:false});releaseSample=null;
await page.waitForTimeout(200);
assert.equal(await panel.locator('#motion-toggle').getAttribute('aria-pressed'),'true','timed-out reply is ignored');
assert.equal(errors.length,0,errors.join('\n'));
console.log(JSON.stringify({result:'PASS',engine:await browser.version(),checks:['spacer paragraphs removed','paragraph control saved','tap toggles controls without reflow','native menu bridge','full viewport reading','reading position restored after reload and rotation','theme persisted','original page toggle','accelerometer scrolling','controls brake motion','reverse direction','inactive sensor stops motion','rotation recalibrates','motion preferences persist','cached history restores motion with a pending native reply','late replies ignored','lost reply timeout recovers']}));
await browser.close();
})().catch(e=>{console.error(e);process.exit(1)});