buninu-browser 0.4.0 → 0.5.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +22 -1
- package/frontends/wayland/README.md +50 -21
- package/frontends/wayland/index.js +14 -0
- package/frontends/wayland/input.js +24 -5
- package/frontends/wayland/urlbar.js +9 -5
- package/lib/audio/shared-connection.js +31 -0
- package/lib/cdp-server.js +15 -2
- package/lib/happy-dom/inert-link.js +26 -0
- package/lib/happy-dom/node-iterator-removal.js +36 -0
- package/lib/happy-dom/node-replacement.js +60 -0
- package/lib/happy-dom/parser.js +4 -1
- package/lib/headless-shell.js +7 -4
- package/lib/layout/text-layout.js +4 -2
- package/lib/paint/display-list.js +2 -1
- package/lib/qml/README.md +456 -0
- package/lib/qml/builtins.js +76 -5
- package/lib/qml/canvas.js +76 -0
- package/lib/qml/document.js +47 -24
- package/lib/qml/fonts.js +69 -0
- package/lib/qml/host.js +28 -0
- package/lib/qml/loader.js +7 -3
- package/lib/qml/models.js +1 -1
- package/lib/qml/reactive.js +26 -3
- package/lib/qml/runtime.js +263 -41
- package/lib/qml/shader/fragment.js +83 -0
- package/lib/qml/shader/qsb.js +27 -0
- package/lib/qml/shader/renderer.js +39 -0
- package/lib/qml/shader/runtime.js +61 -0
- package/lib/qml/shapes.js +9 -1
- package/lib/qml/shortcuts.js +65 -0
- package/lib/qml/textures.js +4 -1
- package/lib/qml/window-constraints.js +16 -0
- package/lib/renderer/bindings.js +110 -4
- package/lib/renderer/canvas-2d.js +16 -0
- package/lib/renderer/document-selection.js +19 -0
- package/lib/renderer/host.js +14 -11
- package/lib/renderer/main.js +2 -0
- package/lib/renderer/page-realm.js +4 -9
- package/lib/renderer/page-renderer.js +283 -39
- package/lib/renderer/pointer-move.js +5 -3
- package/lib/renderer/render-interval.js +9 -0
- package/lib/style/computed-style.js +8 -2
- package/package.json +1 -1
- package/test/audio-connection-lifetime.test.js +46 -0
- package/test/canvas-paint.test.js +8 -0
- package/test/compare-node-iterator.js +42 -0
- package/test/computed-style.test.js +12 -0
- package/test/document-selection.test.js +27 -0
- package/test/double-click.test.js +85 -0
- package/test/fixtures/canvas-damage.js +40 -0
- package/test/fixtures/fake-renderer.js +1 -0
- package/test/fixtures/fractional-text-scale.js +38 -0
- package/test/fixtures/node-iterator-cases.js +59 -0
- package/test/fixtures/node-iterator-chromium.json +315 -0
- package/test/fixtures/omastorm-helpers/Dockerfile +35 -0
- package/test/fixtures/omastorm-helpers/check.mjs +56 -0
- package/test/fixtures/omastorm-helpers/launch.mjs +24 -0
- package/test/fixtures/omastorm-helpers/prepare.mjs +17 -0
- package/test/fixtures/omastorm-helpers/runtime.mjs +32 -0
- package/test/fixtures/pointer-paint-batching.js +23 -0
- package/test/fixtures/qml-fonts/main.qml +10 -0
- package/test/fixtures/qml-locale/check.mjs +6 -0
- package/test/fixtures/qml-locale/main.qml +8 -0
- package/test/fixtures/qml-shader/grab.qml +14 -0
- package/test/fixtures/qml-shader/gradient.frag +5 -0
- package/test/fixtures/qml-shader/main.qml +14 -0
- package/test/fixtures/qml-shader/texture-grab.qml +25 -0
- package/test/fixtures/qml-shader/texture-size.frag +5 -0
- package/test/fixtures/qml-shader-check.mjs +24 -0
- package/test/fixtures/title-no-paint.js +20 -0
- package/test/fractional-text-scale.test.js +6 -0
- package/test/inert-link.test.js +37 -0
- package/test/manual-double-click.html +21 -0
- package/test/manual-mouse-buttons.html +18 -0
- package/test/manual-omastorm-engine.mjs +19 -0
- package/test/manual-omastorm-shaders.mjs +28 -0
- package/test/manual-omastorm-software.mjs +25 -0
- package/test/manual-pointer-scroll.html +43 -0
- package/test/node-iterator.test.js +81 -0
- package/test/page-scripts.test.js +116 -1
- package/test/pointer-drag.test.js +104 -0
- package/test/qml-canvas.test.js +21 -0
- package/test/qml-connections-timing.test.js +22 -0
- package/test/qml-controls-window.test.js +182 -0
- package/test/qml-fonts.test.js +31 -0
- package/test/qml-grid-multiline.test.js +48 -0
- package/test/qml-locale.test.js +45 -0
- package/test/qml-nonvisual-visible.test.js +19 -0
- package/test/qml-shader-runtime.test.js +6 -0
- package/test/qml-shortcuts.test.js +39 -0
- package/test/qml-socket.test.js +60 -0
- package/test/qml-software-shader.test.js +55 -0
- package/test/qml-window-update.test.js +36 -0
- package/test/render-interval.test.js +33 -0
- package/test/text-layout.test.js +15 -0
- package/test/title-no-paint.test.js +6 -0
- package/test/wayland-scroll.test.js +40 -0
- package/test/wayland-urlbar.test.js +23 -0
- package/test/window-resize.test.js +29 -0
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import {strict as assert} from 'node:assert';
|
|
2
|
+
import {createPageRenderer} from '../../lib/renderer/page-renderer.js';
|
|
3
|
+
const texture=process.argv[2]==='texture';
|
|
4
|
+
const grab=process.argv[2]==='grab'||texture;
|
|
5
|
+
const url=new URL(texture?'./qml-shader/texture-grab.qml':grab?'./qml-shader/grab.qml':'./qml-shader/main.qml',import.meta.url).href;
|
|
6
|
+
const errors=[];
|
|
7
|
+
const r=await createPageRenderer({reportError:e=>errors.push(String(e.stack||e)),fetchResource:async url=>({ok:true,status:200,url,body:url.startsWith('data:')?Buffer.from(url.slice(url.indexOf(',')+1),'base64'):await Bun.file(new URL(url)).bytes()})});
|
|
8
|
+
r.resize(640,420,1);r.setScripting('host');
|
|
9
|
+
try{
|
|
10
|
+
await r.loadDocument({url,contentType:'text/x-qml',source:await Bun.file(new URL(url)).text()});await r.whenLoaded();
|
|
11
|
+
await Bun.sleep(100);
|
|
12
|
+
if(!grab){
|
|
13
|
+
const pixel=()=>r.evaluate('Array.from(document.querySelector("canvas").getContext("2d").getImageData(300,160,1,1).data)');
|
|
14
|
+
assert.deepEqual(await pixel(),[128,128,51,255]);
|
|
15
|
+
await r.evaluate('testShader.phase=0.9');await Bun.sleep(100);
|
|
16
|
+
assert.deepEqual(await pixel(),[128,128,230,255]);
|
|
17
|
+
}else{
|
|
18
|
+
for(let i=0;i<100;i++){if(await r.evaluate('!!globalThis.grabPixels'))break;await Bun.sleep(10);}
|
|
19
|
+
assert.deepEqual(await r.evaluate('globalThis.grabPixels'),texture?[0,255,0,255]:[255,0,0,255]);
|
|
20
|
+
assert.equal(await r.evaluate('globalThis.unloaded'),true);
|
|
21
|
+
}
|
|
22
|
+
assert.deepEqual(errors,[]);
|
|
23
|
+
console.log('shader reactive pixels OK');
|
|
24
|
+
}finally{r.dispose();}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
import {strict as assert} from 'node:assert';
|
|
2
|
+
import {createPageRenderer} from '../../lib/renderer/page-renderer.js';
|
|
3
|
+
const traces=[];
|
|
4
|
+
const r=await createPageRenderer({trace:s=>traces.push(s)});
|
|
5
|
+
try{
|
|
6
|
+
r.setScripting('host');
|
|
7
|
+
await r.loadDocument({url:'https://title.test/',contentType:'text/html',source:`<title>Initial</title><p>Before</p><script>globalThis.changeTitle=value=>{document.title=value;document.querySelector('p').textContent='Changed '+value};</script>`});
|
|
8
|
+
await r.whenLoaded();
|
|
9
|
+
assert.equal(r.title(),'Initial');
|
|
10
|
+
// Host scripting allows the fixture to mutate the DOM without evaluate()'s
|
|
11
|
+
// explicit refresh. No onFrame callback: pending work stays pending.
|
|
12
|
+
for(const title of ['Updated','']){
|
|
13
|
+
globalThis.changeTitle(title);
|
|
14
|
+
traces.length=0;
|
|
15
|
+
assert.equal(r.title(),title);
|
|
16
|
+
assert.equal(traces.length,0,'title reads must not rebuild or rasterize');
|
|
17
|
+
await r.screenshot();
|
|
18
|
+
assert.ok(traces.some(s=>s.includes('restyle and relayout')),'DOM mutations remain pending until rendering is requested');
|
|
19
|
+
}
|
|
20
|
+
}finally{delete globalThis.changeTitle;r.dispose()}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import {test,expect} from 'bun:test';
|
|
2
|
+
test('fractional font sizes retain the final glyph at different device scales',async()=>{
|
|
3
|
+
const child=Bun.spawn([process.execPath,new URL('./fixtures/fractional-text-scale.js',import.meta.url).pathname],{stdout:'pipe',stderr:'pipe'});
|
|
4
|
+
const [exit,stderr]=await Promise.all([child.exited,new Response(child.stderr).text()]);
|
|
5
|
+
expect({exit,stderr}).toEqual({exit:0,stderr:''});
|
|
6
|
+
},30000);
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import {test, expect} from 'bun:test';
|
|
2
|
+
import Window from '../vendor/happy-dom/packages/happy-dom/src/window/Window.ts';
|
|
3
|
+
import {createInertWindow} from '../lib/happy-dom/parser.js';
|
|
4
|
+
|
|
5
|
+
test('inert links leave existing and subsequent ordinary windows resource loading intact', async () => {
|
|
6
|
+
const requests=[];
|
|
7
|
+
const server=Bun.serve({port:0,fetch(req){requests.push(new URL(req.url).pathname);return new Response('body{color:red}',{headers:{'content-type':'text/css'}})}});
|
|
8
|
+
const windows=[];
|
|
9
|
+
const ordinary=()=>{const w=new Window({url:server.url.href});windows.push(w);return w};
|
|
10
|
+
const addLink=(w,path)=>{
|
|
11
|
+
const link=w.document.createElement('link'),events=[];
|
|
12
|
+
link.rel='stylesheet';link.href=path;
|
|
13
|
+
link.onload=()=>events.push('load');link.onerror=()=>events.push('error');
|
|
14
|
+
w.document.head.append(link);return {link,events};
|
|
15
|
+
};
|
|
16
|
+
try {
|
|
17
|
+
const before=ordinary();
|
|
18
|
+
const inert=createInertWindow(server.url.href);windows.push(inert);
|
|
19
|
+
const after=ordinary();
|
|
20
|
+
// Repeated setup must not replace the saved native hooks with wrappers.
|
|
21
|
+
windows.push(createInertWindow(server.url.href));
|
|
22
|
+
for(const [w,path] of [[before,'/before.css'],[after,'/after.css']]){
|
|
23
|
+
const {link,events}=addLink(w,path);
|
|
24
|
+
await w.happyDOM.waitUntilComplete();
|
|
25
|
+
expect(events).toEqual(['load']);expect(link.sheet).not.toBeNull();
|
|
26
|
+
link.href=path+'?changed';await w.happyDOM.waitUntilComplete();
|
|
27
|
+
expect(events).toEqual(['load','load']);
|
|
28
|
+
}
|
|
29
|
+
const {link,events}=addLink(inert,'/inert.css');
|
|
30
|
+
link.href='/inert-changed.css';
|
|
31
|
+
await inert.happyDOM.waitUntilComplete();
|
|
32
|
+
expect(events).toEqual([]);expect(link.sheet).toBeNull();
|
|
33
|
+
expect(requests).toEqual(['/before.css','/before.css','/after.css','/after.css']);
|
|
34
|
+
} finally {
|
|
35
|
+
await Promise.all(windows.map(w=>w.happyDOM.abort()));server.stop(true);
|
|
36
|
+
}
|
|
37
|
+
});
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<meta charset="utf-8">
|
|
3
|
+
<title>雙擊測試 · ondblclick</title>
|
|
4
|
+
<style>
|
|
5
|
+
body{margin:0;padding:28px;background:#101c2c;color:#e9f2ff;font-family:sans-serif}
|
|
6
|
+
h1{font-size:26px}p{line-height:1.6;color:#bed0e8}
|
|
7
|
+
#target{display:block;width:100%;height:180px;background:#204763;color:white;border:2px solid #5ad5df;border-radius:12px;font-size:26px}
|
|
8
|
+
#result{padding:18px;font-size:22px;color:#71edcf}
|
|
9
|
+
#stats{padding:14px;background:#192b40;line-height:1.8}
|
|
10
|
+
</style>
|
|
11
|
+
<h1>雙擊才能開啟</h1>
|
|
12
|
+
<p>在下面區域快速點兩下。單擊只更新紀錄,雙擊才會切換開啟/關閉狀態。</p>
|
|
13
|
+
<button id="target" onclick="recordClick(event)" ondblclick="toggleDoor(event)">🔒 雙擊開啟</button>
|
|
14
|
+
<div id="result">等待 dblclick…</div>
|
|
15
|
+
<div id="stats">click:0 dblclick:0 最近 detail:0</div>
|
|
16
|
+
<script>
|
|
17
|
+
let clicks=0,doubles=0,opened=false;
|
|
18
|
+
function stats(detail){document.getElementById('stats').textContent='click:'+clicks+' dblclick:'+doubles+' 最近 detail:'+detail}
|
|
19
|
+
function recordClick(e){clicks++;stats(e.detail)}
|
|
20
|
+
function toggleDoor(e){doubles++;opened=!opened;document.getElementById('target').textContent=opened?'🔓 已開啟,雙擊關閉':'🔒 已關閉,雙擊開啟';document.getElementById('result').textContent='收到 ondblclick!第 '+doubles+' 次';stats(e.detail)}
|
|
21
|
+
</script>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="zh-Hant"><meta charset="utf-8"><title>滑鼠按鍵測試</title>
|
|
3
|
+
<style>
|
|
4
|
+
body{margin:0;padding:28px;background:#101a28;color:#e1f2ff;font-family:sans-serif}
|
|
5
|
+
h1{font-size:28px}p{line-height:1.6}button{display:block;width:100%;height:220px;background:#146d83;color:white;border:3px solid #62dbe9;border-radius:18px;font-size:34px;font-weight:bold}
|
|
6
|
+
#result{margin-top:24px;font-size:27px;color:#70efce}#counts{font-size:22px}pre{white-space:pre-wrap;background:#19283b;padding:16px;font-size:17px}
|
|
7
|
+
</style>
|
|
8
|
+
<h1>左鍵・右鍵・中鍵測試</h1><p>直接點按測左鍵。長按圖片,選 Right click 測右鍵。右鍵不應增加左鍵次數。</p>
|
|
9
|
+
<button id="pad">在這裡點擊 / 長按</button><div id="result">等待按鍵…</div><p id="counts">左鍵 0 | 右鍵 0 | 中鍵 0</p><pre id="events">事件記錄</pre>
|
|
10
|
+
<script>
|
|
11
|
+
const pad=document.getElementById('pad'),result=document.getElementById('result'),counts=document.getElementById('counts'),log=document.getElementById('events');
|
|
12
|
+
const totals=[0,0,0],names=['左鍵','中鍵','右鍵'];let rows=[];
|
|
13
|
+
for(const type of ['mousedown','contextmenu','mouseup','click','auxclick'])pad.addEventListener(type,e=>{
|
|
14
|
+
if(type==='contextmenu')e.preventDefault();
|
|
15
|
+
rows.unshift(type+' button='+e.button+' buttons='+e.buttons);rows=rows.slice(0,10);log.textContent=rows.join('\n');
|
|
16
|
+
if(type==='click'||type==='auxclick'){totals[e.button]++;result.textContent='收到:'+names[e.button];counts.textContent='左鍵 '+totals[0]+' | 右鍵 '+totals[2]+' | 中鍵 '+totals[1];}
|
|
17
|
+
});
|
|
18
|
+
</script></html>
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
// Requires an already-running official engine; never starts or installs one.
|
|
2
|
+
import {mkdtemp,rm} from 'node:fs/promises';
|
|
3
|
+
import {resolve} from 'node:path';
|
|
4
|
+
import {pathToFileURL} from 'node:url';
|
|
5
|
+
import {createBrowser} from '../lib/headless-shell.js';
|
|
6
|
+
const root=resolve(process.argv[2] || '../omastorm');
|
|
7
|
+
const dir=await mkdtemp('/tmp/omastorm-engine-probe-');
|
|
8
|
+
await Bun.write(dir+'/probe.qml',`import QtQuick
|
|
9
|
+
import ${JSON.stringify(pathToFileURL(root+'/ui').href)} as Storm
|
|
10
|
+
Item {Storm.Engine {id:engine} Component.onCompleted:globalThis.engineProbe=engine}`);
|
|
11
|
+
const browser=await createBrowser({spareRenderer:false});
|
|
12
|
+
try {
|
|
13
|
+
await browser.context.navigate(pathToFileURL(dir+'/probe.qml').href);
|
|
14
|
+
for(let i=0;i<100;i++){
|
|
15
|
+
const value=await browser.context.evaluate('globalThis.engineProbe ? {error:engineProbe.error, sites:engineProbe.sites.length,state:engineProbe.state?.type,frame:engineProbe.state?.frame?.kind} : {text:document.body.textContent.slice(0,1000)}');
|
|
16
|
+
if(value?.state || value?.error || value?.text){console.log(JSON.stringify(value));break;}
|
|
17
|
+
await Bun.sleep(50);
|
|
18
|
+
}
|
|
19
|
+
}finally{browser.close();await rm(dir,{recursive:true,force:true});}
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
// Offline, CPU-only compatibility probe. Reads upstream GLSL without modifying it.
|
|
2
|
+
import {resolve} from 'node:path';
|
|
3
|
+
import init from '../usr/lib/canvaskit/canvaskit.js';
|
|
4
|
+
import {compileFragment,makeFragmentShader} from '../lib/qml/shader/fragment.js';
|
|
5
|
+
import {fragmentSource} from '../lib/qml/shader/qsb.js';
|
|
6
|
+
const root=resolve(process.argv[2]||'../omastorm');
|
|
7
|
+
const kit=await init({wasmBinary:await Bun.file(new URL('../usr/lib/canvaskit/canvaskit.wasm',import.meta.url)).arrayBuffer()});
|
|
8
|
+
for(const name of ['tile','texture-check','radar','grid']){
|
|
9
|
+
const c=compileFragment(kit,fragmentSource(await Bun.file(root+'/ui/shaders/'+name+'.frag.qsb').bytes()));
|
|
10
|
+
try{
|
|
11
|
+
console.log(name+': CPU-compatible RuntimeEffect compiled');
|
|
12
|
+
if(!['tile','texture-check'].includes(name))continue;
|
|
13
|
+
const image=kit.MakeImage({width:2,height:1,colorType:kit.ColorType.RGBA_8888,alphaType:kit.AlphaType.Premul,colorSpace:kit.ColorSpace.SRGB},new Uint8Array([255,0,0,255,255,0,0,255]),8);
|
|
14
|
+
const child=image.makeShaderOptions(kit.TileMode.Clamp,kit.TileMode.Clamp,kit.FilterMode.Nearest,kit.MipmapMode.None);
|
|
15
|
+
const shader=makeFragmentShader(c,{width:16,height:16,uniforms:{expected:[2,1],boundaries:[1,0,0,1],water:[0,0,0,0],minorRoads:[0,0,0,0],majorRoads:[0,0,0,0]},textures:{[name==='tile'?'mask':'sweep']:{shader:child,width:2,height:1}}});
|
|
16
|
+
const surface=kit.MakeSurface(16,16),paint=new kit.Paint();
|
|
17
|
+
try{
|
|
18
|
+
paint.setShader(shader);surface.getCanvas().drawRect(kit.XYWHRect(0,0,16,16),paint);surface.flush();
|
|
19
|
+
const result=surface.makeImageSnapshot();
|
|
20
|
+
try{
|
|
21
|
+
const pixel=Array.from(result.readPixels(0,0,{width:1,height:1,colorType:kit.ColorType.RGBA_8888,alphaType:kit.AlphaType.Unpremul,colorSpace:kit.ColorSpace.SRGB}));
|
|
22
|
+
const expected=name==='tile'?[255,0,0,255]:[0,255,0,255];
|
|
23
|
+
if(JSON.stringify(pixel)!==JSON.stringify(expected))throw Error(name+' wrong pixel: '+pixel);
|
|
24
|
+
console.log(name+': CPU pixel '+pixel+' verified');
|
|
25
|
+
}finally{result.delete();}
|
|
26
|
+
}finally{paint.delete();surface.delete();shader.delete();child.delete();image.delete();}
|
|
27
|
+
}finally{c.effect.delete();}
|
|
28
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
// Explicit experiment: copy the application and remove only its GPU policy.
|
|
2
|
+
// Does not alter the checkout, fake GraphicsInfo, or bypass shader/pixel checks.
|
|
3
|
+
import {mkdtemp,cp,readFile,writeFile} from 'node:fs/promises';
|
|
4
|
+
import {resolve} from 'node:path';
|
|
5
|
+
import {tmpdir} from 'node:os';
|
|
6
|
+
const root=resolve(process.argv[2]||'../omastorm');
|
|
7
|
+
const directory=await mkdtemp(resolve(tmpdir(),'omastorm-software-'));
|
|
8
|
+
await cp(root+'/ui',directory+'/ui',{recursive:true});
|
|
9
|
+
const replacements=[
|
|
10
|
+
['TextureCheck.qml', / if \(GraphicsInfo\.api === GraphicsInfo\.Software\) \{\n fail\("Radar requires GPU rendering \(OpenGL\/Vulkan\)\."\);\n return;\n \}\n/],
|
|
11
|
+
['RadarMap.qml', / Component\.onCompleted: if \(GraphicsInfo\.api === GraphicsInfo\.Software\) map\.graphicsError = "Radar requires GPU rendering \(OpenGL\/Vulkan\)\."\n/],
|
|
12
|
+
];
|
|
13
|
+
for(const [file,pattern] of replacements){
|
|
14
|
+
const path=directory+'/ui/'+file,source=await readFile(path,'utf8');
|
|
15
|
+
if(!pattern.test(source))throw Error('Upstream policy changed; review '+file+' before modifying it');
|
|
16
|
+
await writeFile(path,source.replace(pattern,''));
|
|
17
|
+
}
|
|
18
|
+
console.log('Software test copy: '+directory);
|
|
19
|
+
console.log('Original engine must already be running in XDG_RUNTIME_DIR/omastorm.');
|
|
20
|
+
if(process.argv.includes('--prepare-only'))process.exit(0);
|
|
21
|
+
const child=Bun.spawn([process.execPath,new URL('../lib/qml/qswayland',import.meta.url).pathname,'-p',directory+'/ui/shell.qml'],{
|
|
22
|
+
env:{...process.env,OMASTORM_ROOT:root},stdin:'inherit',stdout:'inherit',stderr:'inherit',
|
|
23
|
+
});
|
|
24
|
+
for(const signal of ['SIGINT','SIGTERM','SIGHUP'])process.on(signal,()=>child.kill(signal));
|
|
25
|
+
process.exitCode=await child.exited;
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="zh-Hant">
|
|
3
|
+
<meta charset="utf-8">
|
|
4
|
+
<meta name="viewport" content="width=device-width, initial-scale=1">
|
|
5
|
+
<title>滑鼠與捲動測試</title>
|
|
6
|
+
<style>
|
|
7
|
+
*{box-sizing:border-box}body{margin:0;padding:16px;background:#101923;color:#e4edf5;font:16px sans-serif}h1{font-size:24px}h2{font-size:18px;margin:10px 0}section{padding:14px;margin:16px 0;background:#1c2a38;border:1px solid #446079;border-radius:8px}button,textarea{font:inherit}button{padding:10px;margin:4px;background:#bcebea;color:#102c35;border:0;border-radius:4px}code,pre{font:13px monospace}pre{white-space:pre-wrap;overflow-wrap:anywhere;max-height:240px;overflow:auto}p{line-height:1.5}.box{width:100%;height:220px;border:2px solid #69c7dd;background:#172431}.v{overflow-x:hidden;overflow-y:auto}.h{overflow-x:auto;overflow-y:hidden}.both{overflow:auto}.wide{width:1800px;height:180px;background:linear-gradient(90deg,#375f8b,#426e4d,#9c4f49);padding:20px}.plane{width:1800px;height:1200px;background:repeating-linear-gradient(0deg,transparent,transparent 99px,#7cabc4 100px),repeating-linear-gradient(90deg,#263d52,#263d52 99px,#7cabc4 100px);padding:20px}.row{padding:18px;border-bottom:1px solid #436479}.readout{color:#8de1e8;margin:8px 0}#target{display:block;width:100%;min-height:100px;background:#286a75;color:white}canvas{display:block;width:100%;height:220px;background:#f5fafb;touch-action:none}textarea{width:100%;height:110px}label{display:block;margin:8px 0}
|
|
8
|
+
</style>
|
|
9
|
+
<h1>滑鼠與捲動測試</h1>
|
|
10
|
+
<p>各區獨立捲動。先把游標放在指定區域再操作,觀察位置與事件紀錄。Shift/Ctrl/Alt/Super 是否到達頁面取決於外層終端與系統;本頁不自行交換滾輪軸向。</p>
|
|
11
|
+
<section><h2>1. 垂直捲動</h2><div class="readout" id="v-state"></div><div id="v" class="box v"></div></section>
|
|
12
|
+
<section><h2>2. 水平捲動</h2><div class="readout" id="h-state"></div><div id="h" class="box h"><div class="wide">左端 0 ← 水平 1800 px → 右端<br><br>試水平手勢或 Shift+滾輪;若事件仍是 deltaY,請看外層終端是否轉換。</div></div></section>
|
|
13
|
+
<section><h2>3. 雙向捲動</h2><div class="readout" id="both-state"></div><div id="both" class="box both"><div class="plane">左上角 (0, 0) — 每格約 100 px;寬 1800、高 1200。</div></div><button id="reset">重設三區捲動</button></section>
|
|
14
|
+
<section><h2>4. 按鍵、雙擊與修飾鍵</h2><p>此按鈕攔截原生右鍵選單,只在這裡記錄 contextmenu。中鍵不捲頁。可按住修飾鍵再點擊。</p><button id="target">左鍵/右鍵/中鍵/雙擊這裡</button><div id="counts" class="readout"></div></section>
|
|
15
|
+
<section><h2>5. 按住左鍵拖曳畫線</h2><p>手機可雙擊並按住第二下拖曳。放開後再移動不應畫線;也可測移出畫布後放開。按住狀態與座標列在下方。</p><canvas id="paint" width="1000" height="300"></canvas><div id="drag" class="readout">尚未按下</div><button id="clear-paint">清除畫布</button></section>
|
|
16
|
+
<section id="text-selection"><h2>6. 文字選取與編輯</h2><p>這段可拖曳選取:春天的河水流過城市,0123456789,The quick brown fox。</p><textarea>第一行:滑鼠點擊定位,Shift+方向鍵選取。
|
|
17
|
+
第二行:測試 Ctrl-C/Ctrl-V、原生長按貼上。
|
|
18
|
+
第三行:這裡不自行實作 PRIMARY 選取或中鍵貼上。</textarea></section>
|
|
19
|
+
<section><h2>事件紀錄(最多 40 筆)</h2><label><input id="moves" type="checkbox"> 記錄 mousemove(會產生大量事件)</label><button id="clear-log">清除紀錄</button><pre id="log" aria-live="off"></pre></section>
|
|
20
|
+
<script>
|
|
21
|
+
const $=id=>document.getElementById(id), records=[], counts={left:0,right:0,middle:0,double:0};
|
|
22
|
+
function log(e){const mods=['shiftKey','ctrlKey','altKey','metaKey'].filter(k=>e[k]).map(k=>k.replace('Key','')).join('+')||'-';let msg=e.type+' target='+(e.target.id||e.target.tagName)+' button='+e.button+' buttons='+e.buttons+' xy='+Math.round(e.clientX||0)+','+Math.round(e.clientY||0)+' modifiers='+mods;if(e.type==='wheel')msg+=' delta='+e.deltaX+','+e.deltaY+' mode='+e.deltaMode;if(e.type==='click'||e.type==='dblclick'||e.type==='auxclick')msg+=' detail='+e.detail;records.unshift(msg);records.length=Math.min(40,records.length);$('log').textContent=records.join('\n')}
|
|
23
|
+
for(const type of ['mousedown','mouseup','click','dblclick','auxclick','contextmenu','wheel'])document.addEventListener(type,log,{capture:true,passive:true});
|
|
24
|
+
document.addEventListener('mousemove',e=>{if($('moves').checked)log(e)},{passive:true});
|
|
25
|
+
for(let i=0;i<20;i++){const r=document.createElement('div');r.className='row';r.textContent='垂直第 '+(i+1)+' 列';$('v').appendChild(r)}
|
|
26
|
+
for(const id of ['v','h','both']){const box=$(id),update=()=>{$(id+'-state').textContent='scrollLeft='+Math.round(box.scrollLeft)+' / '+Math.max(0,box.scrollWidth-box.clientWidth)+' · scrollTop='+Math.round(box.scrollTop)+' / '+Math.max(0,box.scrollHeight-box.clientHeight)};box.addEventListener('scroll',update);window.addEventListener('resize',update);update()}
|
|
27
|
+
$('reset').onclick=()=>{for(const id of ['v','h','both']){$(id).scrollLeft=0;$(id).scrollTop=0}};
|
|
28
|
+
const count=()=>{$('counts').textContent='左鍵 '+counts.left+' · 右鍵 '+counts.right+' · 中鍵 '+counts.middle+' · 雙擊 '+counts.double};count();
|
|
29
|
+
$('target').onclick=e=>{if(e.button===0){counts.left++;count()}};
|
|
30
|
+
$('target').oncontextmenu=e=>{e.preventDefault();counts.right++;count()};
|
|
31
|
+
$('target').onauxclick=e=>{e.preventDefault();if(e.button===1){counts.middle++;count()}};
|
|
32
|
+
$('target').onmousedown=e=>{if(e.button===1)e.preventDefault()};
|
|
33
|
+
$('target').ondblclick=()=>{counts.double++;count()};
|
|
34
|
+
const canvas=$('paint'),ctx=canvas.getContext('2d');let drawing=false,last=null;
|
|
35
|
+
const point=e=>{const r=canvas.getBoundingClientRect();return [(e.clientX-r.left)*canvas.width/r.width,(e.clientY-r.top)*canvas.height/r.height]};
|
|
36
|
+
canvas.onmousedown=e=>{if(e.button!==0)return;e.preventDefault();drawing=true;last=point(e);$('drag').textContent='按住左鍵'};
|
|
37
|
+
document.addEventListener('mousemove',e=>{if(!drawing)return;if(!(e.buttons&1)){drawing=false;$('drag').textContent='左鍵已放開';return}const p=point(e);ctx.strokeStyle='#136a8b';ctx.lineWidth=3;ctx.beginPath();ctx.moveTo(...last);ctx.lineTo(...p);ctx.stroke();last=p;$('drag').textContent='拖曳 '+p.map(Math.round).join(',')});
|
|
38
|
+
document.addEventListener('mouseup',e=>{if(e.button===0){drawing=false;$('drag').textContent='左鍵已放開'}});
|
|
39
|
+
window.addEventListener('blur',()=>{drawing=false;$('drag').textContent='失焦,停止畫線'});
|
|
40
|
+
$('clear-paint').onclick=()=>ctx.clearRect(0,0,canvas.width,canvas.height);
|
|
41
|
+
$('clear-log').onclick=()=>{records.length=0;$('log').textContent=''};
|
|
42
|
+
</script>
|
|
43
|
+
</html>
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { afterAll, beforeAll, expect, test } from 'bun:test';
|
|
2
|
+
import { RendererHost } from '../lib/renderer/host.js';
|
|
3
|
+
import { nodeIteratorCases } from './fixtures/node-iterator-cases.js';
|
|
4
|
+
import chromium from './fixtures/node-iterator-chromium.json';
|
|
5
|
+
|
|
6
|
+
const host = new RendererHost({timeout: 20000, fetch: async () => new Response('')});
|
|
7
|
+
beforeAll(async () => {
|
|
8
|
+
await host.call('loadDocument', {url:'https://iterator.test/',source:'<body></body>',contentType:'text/html',status:200});
|
|
9
|
+
await host.call('whenLoaded');
|
|
10
|
+
});
|
|
11
|
+
afterAll(() => host.close());
|
|
12
|
+
const evaluate = async expression => JSON.parse(await host.call('evaluate', `JSON.stringify(${expression})`));
|
|
13
|
+
|
|
14
|
+
test('NodeIterator traversal, replacement and WebIDL conversion match Chromium and DOM', async () => {
|
|
15
|
+
const actual = await evaluate(`(${nodeIteratorCases})()`);
|
|
16
|
+
const expected = chromium.map(row => {
|
|
17
|
+
// Chromium retains a detached reference when replacing a node with its
|
|
18
|
+
// following sibling while the pointer is before it. DOM replace adopts
|
|
19
|
+
// (detaches) that sibling first, then removes the old node: the repaired
|
|
20
|
+
// reference must be root, not the now-detached old node/descendant.
|
|
21
|
+
// https://dom.spec.whatwg.org/#concept-node-replace steps 6–7
|
|
22
|
+
if (row[1] === 'sibling' && [1,2].includes(row[2]) && row[3]) {
|
|
23
|
+
return [...row.slice(0,4), 'root', false, 'b', 'b', 'b'];
|
|
24
|
+
}
|
|
25
|
+
return row;
|
|
26
|
+
});
|
|
27
|
+
expect(actual).toEqual(expected);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
test('failed replacements leave the tree and iterator position unchanged', async () => {
|
|
31
|
+
expect(await evaluate(`(() => {
|
|
32
|
+
const results=[];
|
|
33
|
+
for(const kind of ['ancestor','missing','document','textDocument','twoElements']) {
|
|
34
|
+
const root=document.createElement('div');root.id='root';root.innerHTML='<b id="a"></b><i id="b"></i>';
|
|
35
|
+
const doc=document.implementation.createHTMLDocument('');
|
|
36
|
+
const parent=kind==='textDocument'||kind==='twoElements'?doc:root;
|
|
37
|
+
const old=parent.firstChild;
|
|
38
|
+
const it=document.createNodeIterator(parent);it.nextNode();it.nextNode();
|
|
39
|
+
const reference=it.referenceNode,before=it.pointerBeforeReferenceNode;
|
|
40
|
+
const children=[...parent.childNodes];
|
|
41
|
+
let incoming=kind==='ancestor'?root:kind==='document'?document:document.createElement('em');
|
|
42
|
+
if(kind==='textDocument')incoming=document.createTextNode('invalid');
|
|
43
|
+
if(kind==='twoElements'){incoming=document.createDocumentFragment();incoming.append(document.createElement('i'),document.createElement('b'));}
|
|
44
|
+
let error;
|
|
45
|
+
try{parent.replaceChild(incoming,kind==='missing'?document.createElement('b'):old)}catch(e){error=e.name}
|
|
46
|
+
results.push([kind,error,it.referenceNode===reference,it.pointerBeforeReferenceNode===before,
|
|
47
|
+
children.length===parent.childNodes.length&&children.every((n,i)=>n===parent.childNodes[i])]);
|
|
48
|
+
}
|
|
49
|
+
return results;
|
|
50
|
+
})()`)).toEqual([
|
|
51
|
+
['ancestor','HierarchyRequestError',true,true,true],['missing','NotFoundError',true,true,true],
|
|
52
|
+
['document','HierarchyRequestError',true,true,true],['textDocument','HierarchyRequestError',true,true,true],
|
|
53
|
+
['twoElements','HierarchyRequestError',true,true,true]
|
|
54
|
+
]);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test('filter replacement repairs both active and stored iterator cursors', async () => {
|
|
58
|
+
expect(await evaluate(`(() => {
|
|
59
|
+
const root=document.createElement('div');root.id='root';root.innerHTML='<b id="a"></b><i id="b"></i>';
|
|
60
|
+
const it=document.createNodeIterator(root,1,n=>{
|
|
61
|
+
if(n.id==='a'){const replacement=document.createElement('em');replacement.id='x';root.replaceChild(replacement,n)}
|
|
62
|
+
return 1;
|
|
63
|
+
});
|
|
64
|
+
const got=[];for(let n,i=0;i<8&&(n=it.nextNode());i++)got.push(n.id);
|
|
65
|
+
return got;
|
|
66
|
+
})()`)).toEqual(['root','a','x','b']);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test('batch removal follows DOM tree order and keeps references attached', async () => {
|
|
70
|
+
expect(await evaluate(`(() => {
|
|
71
|
+
const results=[];
|
|
72
|
+
for(const operation of ['replaceChildren','innerHTML','textContent'])for(const before of [false,true]) {
|
|
73
|
+
const root=document.createElement('div');root.innerHTML='<i>a</i><i>b</i>';
|
|
74
|
+
const it=document.createNodeIterator(root);it.nextNode();it.nextNode();it.nextNode();
|
|
75
|
+
if(before)it.previousNode();
|
|
76
|
+
if(operation==='replaceChildren')root.replaceChildren();else root[operation]='';
|
|
77
|
+
results.push([it.referenceNode===root,it.pointerBeforeReferenceNode,it.nextNode()]);
|
|
78
|
+
}
|
|
79
|
+
return results;
|
|
80
|
+
})()`)).toEqual(Array.from({length:6},()=>[true,false,null]));
|
|
81
|
+
});
|
|
@@ -1997,14 +1997,24 @@ test("a click inside an iframe goes to the element under it in the frame's docum
|
|
|
1997
1997
|
test("dynamic linked stylesheets fire load after their rules become available", async () => {
|
|
1998
1998
|
await load(`<div id="target"></div><script>
|
|
1999
1999
|
window.sheetLoaded = false;
|
|
2000
|
+
window.sheetEvents = [];
|
|
2000
2001
|
const link = document.createElement('link');
|
|
2001
2002
|
link.rel = 'stylesheet';
|
|
2002
2003
|
link.href = '/slow.css';
|
|
2003
|
-
|
|
2004
|
+
window.styleTestLink = link;
|
|
2005
|
+
link.onerror = () => sheetEvents.push('error');
|
|
2006
|
+
link.onload = () => { sheetEvents.push('load'); window.sheetLoaded = getComputedStyle(document.body).color; };
|
|
2004
2007
|
document.head.appendChild(link);
|
|
2005
2008
|
</script>`);
|
|
2006
2009
|
for (let i = 0; i < 60 && await evaluate('sheetLoaded') === false; i++) await Bun.sleep(25);
|
|
2007
2010
|
expect(await evaluate('sheetLoaded')).toBe('rgba(255, 0, 0, 1)');
|
|
2011
|
+
expect(await evaluate('sheetEvents.join()')).toBe('load');
|
|
2012
|
+
await evaluate("styleTestLink.href='/missing.css'; window.eventsAfterHref=sheetEvents.join()");
|
|
2013
|
+
expect(await evaluate('eventsAfterHref')).toBe('load');
|
|
2014
|
+
expect(await waitFor("sheetEvents.join() === 'load,error'")).toBe(true);
|
|
2015
|
+
await evaluate("styleTestLink.href='/slow.css'; window.eventsAfterHref=sheetEvents.join()");
|
|
2016
|
+
expect(await evaluate('eventsAfterHref')).toBe('load,error');
|
|
2017
|
+
expect(await waitFor("sheetEvents.join() === 'load,error,load'")).toBe(true);
|
|
2008
2018
|
});
|
|
2009
2019
|
|
|
2010
2020
|
test("decoded dynamic images report natural dimensions and fire load", async () => {
|
|
@@ -2154,3 +2164,108 @@ test("the page scrolls over content that overflows a 100vh layout, not over clip
|
|
|
2154
2164
|
expect(await evaluate(`(scrollTo(0, 99999), [scrollY, Math.round(document.getElementById("end").getBoundingClientRect().bottom)])`))
|
|
2155
2165
|
.toEqual([2090 - 300, 300]);
|
|
2156
2166
|
});
|
|
2167
|
+
|
|
2168
|
+
test("NodeIterator includes its root, preserves direction, and skips rejected nodes without pruning", async () => {
|
|
2169
|
+
await load('<div id="root"><p id="a"><b id="child"></b></p><!--note--><p id="b"></p></div>');
|
|
2170
|
+
expect(await evaluate(`(() => {
|
|
2171
|
+
const root = document.getElementById('root');
|
|
2172
|
+
const filter = {acceptNode(n) {return n.id === 'a' ? NodeFilter.FILTER_REJECT : NodeFilter.FILTER_ACCEPT}};
|
|
2173
|
+
const it = document.createNodeIterator(root, NodeFilter.SHOW_ELEMENT, filter);
|
|
2174
|
+
const initial = [it instanceof NodeIterator,it.root === root,it.referenceNode === root,it.pointerBeforeReferenceNode,it.filter === filter];
|
|
2175
|
+
const forward=[];let n;while(n=it.nextNode())forward.push(n.id);
|
|
2176
|
+
const reverse=[];while(n=it.previousNode())reverse.push(n.id);
|
|
2177
|
+
it.detach();
|
|
2178
|
+
return JSON.stringify([initial,forward,reverse,it.nextNode().id]);
|
|
2179
|
+
})()`)).toBe(JSON.stringify([[true,true,true,true,true],['root','child','b'],['b','child','root'],'root']));
|
|
2180
|
+
});
|
|
2181
|
+
|
|
2182
|
+
test("NodeIterator survives sanitizer removals, reparenting, and rejected reentrant filters", async () => {
|
|
2183
|
+
await load('<div id="root"><i id="a"><b id="child"></b></i><i id="b"></i><i id="c"></i></div>');
|
|
2184
|
+
expect(await evaluate(`(() => {
|
|
2185
|
+
const root=document.getElementById('root'),it=document.createNodeIterator(root,1);
|
|
2186
|
+
it.nextNode();it.nextNode();it.nextNode();
|
|
2187
|
+
root.firstChild.remove();
|
|
2188
|
+
const removed=[it.referenceNode.id,it.nextNode().id];
|
|
2189
|
+
it.previousNode();root.firstChild.remove();
|
|
2190
|
+
const before=[it.referenceNode.id,it.pointerBeforeReferenceNode,it.nextNode().id];
|
|
2191
|
+
document.body.appendChild(root.firstChild);
|
|
2192
|
+
const moved=[it.referenceNode.id,it.nextNode()];
|
|
2193
|
+
let error;const recur=document.createNodeIterator(root,1,()=>{try{recur.nextNode()}catch(e){error=e.name}return 1});
|
|
2194
|
+
recur.nextNode();
|
|
2195
|
+
const fresh=document.createNodeIterator(root,NodeFilter.SHOW_TEXT);
|
|
2196
|
+
root.innerHTML='hello<b>world</b>';
|
|
2197
|
+
return JSON.stringify([removed,before,moved,error,fresh.nextNode().data,fresh.nextNode().data]);
|
|
2198
|
+
})()`)).toBe(JSON.stringify([['root','b'],['c',true,'c'],['root',null],'InvalidStateError','hello','world']));
|
|
2199
|
+
});
|
|
2200
|
+
|
|
2201
|
+
// Expectations independently checked against Chromium Headless Shell.
|
|
2202
|
+
test("NodeIterator repairs an active filter cursor when the candidate or its ancestor is removed", async () => {
|
|
2203
|
+
await load("<body></body>");
|
|
2204
|
+
expect(await evaluate(`JSON.stringify((()=>{const results=[];for(const direction of ['nextNode','previousNode'])for(const remove of ['self','ancestor'])for(const verdict of [1,3]){
|
|
2205
|
+
const root=document.createElement('div');root.id='root';root.innerHTML='<i id="a"></i><section id="parent"><b id="b"></b><b id="c"></b></section><i id="d"></i>';document.body.append(root);
|
|
2206
|
+
let armed=false,once=false;const seen=[];const it=document.createNodeIterator(root,1,n=>{if(armed)seen.push(n.id);if(armed&&!once&&n.id==='b'){once=true;(remove==='self'?n:n.parentNode).remove();return verdict;}return 1});
|
|
2207
|
+
if(direction==='previousNode')while(it.nextNode());armed=true;
|
|
2208
|
+
const got=[];for(let n,k=0;k<15&&(n=it[direction]());k++)got.push(n.id);
|
|
2209
|
+
results.push({direction,remove,verdict,seen,got,reference:it.referenceNode.id,before:it.pointerBeforeReferenceNode});root.remove();
|
|
2210
|
+
}return results.map(r=>[r.direction,r.remove,r.verdict,r.seen.join(','),r.got.join(','),r.reference,r.before])})())`)).toBe(JSON.stringify([
|
|
2211
|
+
["nextNode", "self", 1, "root,a,parent,b,c,d", "root,a,parent,b,c,d", "d", false],
|
|
2212
|
+
["nextNode", "self", 3, "root,a,parent,b,c,d", "root,a,parent,c,d", "d", false],
|
|
2213
|
+
["nextNode", "ancestor", 1, "root,a,parent,b,d", "root,a,parent,b,d", "d", false],
|
|
2214
|
+
["nextNode", "ancestor", 3, "root,a,parent,b,d", "root,a,parent,d", "d", false],
|
|
2215
|
+
["previousNode", "self", 1, "d,c,b,parent,a,root", "d,c,b,parent,a,root", "root", true],
|
|
2216
|
+
["previousNode", "self", 3, "d,c,b,parent,a,root", "d,c,parent,a,root", "root", true],
|
|
2217
|
+
["previousNode", "ancestor", 1, "d,c,b,a,root", "d,c,b,a,root", "root", true],
|
|
2218
|
+
["previousNode", "ancestor", 3, "d,c,b,a,root", "d,c,a,root", "root", true]
|
|
2219
|
+
]));
|
|
2220
|
+
});
|
|
2221
|
+
|
|
2222
|
+
test("NodeIterator releases its active cursor after a filter throws", async () => {
|
|
2223
|
+
await load('<div id="root"><b id="child"></b></div>');
|
|
2224
|
+
expect(await evaluate(`(() => {
|
|
2225
|
+
const root=document.getElementById('root');let first=true;
|
|
2226
|
+
const it=document.createNodeIterator(root,1,()=>{if(first){first=false;throw Error('filter failed')}return 1});
|
|
2227
|
+
let message;try{it.nextNode()}catch(e){message=e.message}
|
|
2228
|
+
return [message,it.nextNode().id,it.nextNode().id].join(',');
|
|
2229
|
+
})()`)).toBe('filter failed,root,child');
|
|
2230
|
+
});
|
|
2231
|
+
|
|
2232
|
+
test("NodeIterator repairs removals through proxy-backed select and form parents", async () => {
|
|
2233
|
+
await load('<body></body>');
|
|
2234
|
+
expect(await evaluate(`(() => {
|
|
2235
|
+
const results=[];
|
|
2236
|
+
for(const tag of ['div','select','form']) for(const outerRoot of [false,true])
|
|
2237
|
+
for(const operation of ['remove','removeChild','innerHTML','reparent']) {
|
|
2238
|
+
const outer=document.createElement('div'),parent=document.createElement(tag);
|
|
2239
|
+
outer.append(parent);document.body.append(outer);
|
|
2240
|
+
parent.innerHTML='<option id="a"></option><option id="b"></option>';
|
|
2241
|
+
const it=document.createNodeIterator(outerRoot?outer:parent,1);
|
|
2242
|
+
while(it.nextNode()?.id!=='a') {}
|
|
2243
|
+
const child=parent.firstChild;
|
|
2244
|
+
if(operation==='remove')child.remove();
|
|
2245
|
+
else if(operation==='removeChild')parent.removeChild(child);
|
|
2246
|
+
else if(operation==='innerHTML')parent.innerHTML='';
|
|
2247
|
+
else outer.append(child);
|
|
2248
|
+
results.push([it.referenceNode===parent,it.nextNode()?.id??null]);
|
|
2249
|
+
outer.remove();
|
|
2250
|
+
}
|
|
2251
|
+
return JSON.stringify(results);
|
|
2252
|
+
})()`)).toBe(JSON.stringify(Array.from({length:6},()=>[
|
|
2253
|
+
[true,'b'],[true,'b'],[true,null],[true,'b']
|
|
2254
|
+
]).flat()));
|
|
2255
|
+
});
|
|
2256
|
+
|
|
2257
|
+
test("NodeIterator validates nullable callback-interface filters at creation", async () => {
|
|
2258
|
+
await load('<body></body>');
|
|
2259
|
+
expect(await evaluate(`(() => {
|
|
2260
|
+
const results=[];
|
|
2261
|
+
for(const filter of [0,1,false,true,'','x',Symbol('filter'),1n]) {
|
|
2262
|
+
try { document.createNodeIterator(document.body,0,filter);results.push('accepted'); }
|
|
2263
|
+
catch(error) { results.push(error.name); }
|
|
2264
|
+
}
|
|
2265
|
+
for(const filter of [null,undefined,()=>1,{acceptNode(){return 1}},{}]) {
|
|
2266
|
+
const it=document.createNodeIterator(document.body,0,filter);
|
|
2267
|
+
results.push(it.filter===(filter??null) && it.nextNode()===null);
|
|
2268
|
+
}
|
|
2269
|
+
return JSON.stringify(results);
|
|
2270
|
+
})()`)).toBe(JSON.stringify([...Array(8).fill('TypeError'),...Array(5).fill(true)]));
|
|
2271
|
+
});
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
import {test,expect} from 'bun:test';
|
|
2
|
+
import {mkdtempSync,rmSync} from 'node:fs';
|
|
3
|
+
import {createBrowser} from '../lib/headless-shell.js';
|
|
4
|
+
import {CdpServer} from '../lib/cdp-server.js';
|
|
5
|
+
import {CDP,launch} from '../tools/browser-lab.js';
|
|
6
|
+
import {WaylandUrlBar} from '../frontends/wayland/urlbar.js';
|
|
7
|
+
|
|
8
|
+
const source=`<style>body{margin:0}#box{width:300px;height:160px}#a,#b{display:inline-block;width:100px;height:100px}canvas{display:block}</style>
|
|
9
|
+
<div id="box"><span id="a">A</span><span id="b">B</span></div><input id="field" value="hello"><canvas id="paint" width="300" height="100"></canvas>
|
|
10
|
+
<script>
|
|
11
|
+
globalThis.paint=document.getElementById("paint");globalThis.a=document.getElementById("a");globalThis.field=document.getElementById("field");
|
|
12
|
+
globalThis.events=[];globalThis.cancelDown=false;globalThis.cancelPointer=false;
|
|
13
|
+
for(const type of ['pointerdown','mousedown','pointermove','mousemove','pointerup','mouseup','click','auxclick'])document.addEventListener(type,e=>{events.push([type,e.target.id,e.button,e.buttons,e.shiftKey]);if(type==='mousedown'&&cancelDown)e.preventDefault();if(type==='pointerdown'&&cancelPointer)e.preventDefault()});
|
|
14
|
+
let drawing=false;const ctx=paint.getContext('2d');paint.onmousedown=()=>drawing=true;document.addEventListener('mouseup',()=>drawing=false);paint.onmousemove=e=>{if(drawing&&(e.buttons&1)){ctx.fillStyle='red';ctx.fillRect(0,0,20,20)}};
|
|
15
|
+
</script>`;
|
|
16
|
+
async function check(c){
|
|
17
|
+
await c.send('Page.navigate',{url:'data:text/html,'+encodeURIComponent(source)});
|
|
18
|
+
for(let i=0;i<200;i++){if(await c.evaluate('!!document.getElementById("paint")'))break;await Bun.sleep(20)}
|
|
19
|
+
const send=(type,x=20,y=20,extra={})=>c.send('Input.dispatchMouseEvent',{type,x,y,button:'left',buttons:type==='mouseReleased'?0:1,clickCount:1,...extra});
|
|
20
|
+
await send('mouseMoved',20,20,{buttons:0,button:'none'});await c.evaluate('events=[]');
|
|
21
|
+
await send('mousePressed',20,20,{modifiers:8});
|
|
22
|
+
expect(await c.evaluate('events')).toEqual([['pointerdown','a',0,1,true],['mousedown','a',0,1,true]]);
|
|
23
|
+
await send('mouseMoved',40,40,{modifiers:8});
|
|
24
|
+
expect((await c.evaluate('events')).slice(-2)).toEqual([['pointermove','a',-1,1,true],['mousemove','a',0,1,true]]);
|
|
25
|
+
await send('mouseReleased',40,40,{modifiers:8});
|
|
26
|
+
expect((await c.evaluate('events')).slice(-3)).toEqual([['pointerup','a',0,0,true],['mouseup','a',0,0,true],['click','a',0,0,true]]);
|
|
27
|
+
// Sibling press/release activates the common ancestor, never the release sibling.
|
|
28
|
+
await c.evaluate('events=[]');await send('mousePressed');await send('mouseReleased',140,20);
|
|
29
|
+
expect((await c.evaluate('events')).at(-1)).toEqual(['click','box',0,0,false]);
|
|
30
|
+
// A disconnected press target cannot activate another element on release.
|
|
31
|
+
await send('mousePressed');await c.evaluate('a.remove();events=[]');await send('mouseReleased');
|
|
32
|
+
expect((await c.evaluate('events')).some(e=>e[0]==='click')).toBe(false);
|
|
33
|
+
// Canceled mousedown must not focus the editor; normal focus happens on press.
|
|
34
|
+
const rect=await c.evaluate('field.getBoundingClientRect().toJSON()');
|
|
35
|
+
await c.evaluate('cancelDown=true');await send('mousePressed',rect.x+10,rect.y+8);
|
|
36
|
+
expect(await c.evaluate('document.activeElement.id')).not.toBe('field');await send('mouseReleased',rect.x+10,rect.y+8);
|
|
37
|
+
await c.evaluate('cancelDown=false');await send('mousePressed',rect.x+10,rect.y+8);
|
|
38
|
+
expect(await c.evaluate('document.activeElement.id')).toBe('field');await send('mouseReleased',rect.x+10,rect.y+8);
|
|
39
|
+
// pointerdown cancellation suppresses compatibility mouse events, not click.
|
|
40
|
+
await c.evaluate('cancelPointer=true;events=[]');await send('mousePressed',20,20);await send('mouseMoved',30,30);await send('mouseReleased',30,30);
|
|
41
|
+
expect((await c.evaluate('events')).map(e=>e[0])).toEqual(['pointerdown','pointermove','pointerup','click']);
|
|
42
|
+
await c.evaluate('cancelPointer=false;events=[]');
|
|
43
|
+
// The same canvas drawing contract as the manual test, checked before release.
|
|
44
|
+
const paint=await c.evaluate('paint.getBoundingClientRect().toJSON()');
|
|
45
|
+
await send('mousePressed',paint.x+20,paint.y+20);await send('mouseMoved',paint.x+40,paint.y+40);
|
|
46
|
+
expect(await c.evaluate('Array.from(paint.getContext("2d").getImageData(5,5,1,1).data)')).toEqual([255,0,0,255]);
|
|
47
|
+
await send('mouseReleased',paint.x+40,paint.y+40);await send('mouseMoved',20,20,{buttons:0,button:'none'});
|
|
48
|
+
expect((await c.evaluate('events')).at(-1)[3]).toBe(0);
|
|
49
|
+
}
|
|
50
|
+
test('CDP real pointer phases preserve dragging, focus and click targets',async()=>{
|
|
51
|
+
const b=await createBrowser({spareRenderer:false}),server=CdpServer.create(b.context).listen(0),c=new CDP(`ws://127.0.0.1:${server.port}/devtools/browser/cdp-server`);
|
|
52
|
+
try{await check(c)}finally{c.close();server.stop(true);b.close()}
|
|
53
|
+
},30000);
|
|
54
|
+
test.skipIf(process.env.BUNINU_TEST_CHROMIUM!=='1')('Chromium reference uses the same pointer event contract',async()=>{
|
|
55
|
+
const dir=mkdtempSync('/tmp/pointer-chromium-');let app;
|
|
56
|
+
try{app=await launch('chromium',dir);await check(app.cdp)}finally{await app?.stop();rmSync(dir,{recursive:true,force:true})}
|
|
57
|
+
},30000);
|
|
58
|
+
test('Wayland toolbar sends real press and motion before release, including over chrome',async()=>{
|
|
59
|
+
const b=await createBrowser({spareRenderer:false});const bar=new WaylandUrlBar(b.context,{history:{list:()=>[],add(){}}});
|
|
60
|
+
try{
|
|
61
|
+
await b.context.navigate('data:text/html,'+encodeURIComponent(source));
|
|
62
|
+
await bar.pointerButton(20,bar.height+20,272,1,{clickCount:1});
|
|
63
|
+
expect((await b.context.evaluate('events')).map(e=>e[0])).toEqual(['pointerdown','mousedown']);
|
|
64
|
+
await bar.move(40,bar.height+40);
|
|
65
|
+
expect((await b.context.evaluate('events')).at(-1)[3]).toBe(1);
|
|
66
|
+
await bar.pointerButton(40,0,272,0,{clickCount:1});
|
|
67
|
+
await bar.move(40,bar.height+40);
|
|
68
|
+
expect((await b.context.evaluate('events')).at(-1)[3]).toBe(0);
|
|
69
|
+
expect(bar.editor.editing).toBe(false);
|
|
70
|
+
}finally{bar.dispose();b.close()}
|
|
71
|
+
},30000);
|
|
72
|
+
|
|
73
|
+
test.skipIf(process.env.BUNINU_TEST_WAYLAND!=='1')('real Wayland pointer draws on the unchanged manual test page',async()=>{
|
|
74
|
+
const {start}=await import('../../buninu-land/compositor.js');
|
|
75
|
+
const {launch:wayland}=await import('../frontends/wayland/index.js');
|
|
76
|
+
const dir=mkdtempSync('/tmp/pointer-wayland-'),saved=process.env.XDG_RUNTIME_DIR;
|
|
77
|
+
process.env.XDG_RUNTIME_DIR=dir;let land,app;
|
|
78
|
+
const until=async fn=>{for(let n=0;n<200;n++){if(await fn())return;await Bun.sleep(20)}throw Error('Wayland pointer timed out')};
|
|
79
|
+
try{
|
|
80
|
+
land=await start(['--remote-debugging-port=0','--window-size=800,700']);
|
|
81
|
+
app=await wayland({socket:dir+'/wayland-buninu',url:Bun.pathToFileURL(new URL('./manual-pointer-scroll.html',import.meta.url).pathname).href});
|
|
82
|
+
const c=app.browser.context;
|
|
83
|
+
await until(()=>land.compositor.wm.renderSurfaces().some(s=>s.pixels));
|
|
84
|
+
const top=await c.evaluate('document.getElementById("paint").getBoundingClientRect().top');
|
|
85
|
+
await c.scroll(0,top-100);
|
|
86
|
+
const rect=await c.evaluate('document.getElementById("paint").getBoundingClientRect().toJSON()');
|
|
87
|
+
const surface=land.compositor.wm.renderSurfaces().find(s=>s.pixels),origin=land.compositor.wm.origin(surface);
|
|
88
|
+
const x=origin.x+rect.x+40,y=origin.y+app.toolbar.height+rect.y+40;
|
|
89
|
+
land.context.dispatchMouseEvent({type:'mouseMoved',x,y});
|
|
90
|
+
land.context.dispatchMouseEvent({type:'mousePressed',x,y,button:'left',buttons:1});
|
|
91
|
+
await until(async()=>(await c.evaluate('document.getElementById("drag").textContent'))==='按住左鍵');
|
|
92
|
+
land.context.dispatchMouseEvent({type:'mouseMoved',x:x+80,y:y+40,button:'left',buttons:1});
|
|
93
|
+
await until(async()=>await c.evaluate('(()=>{const c=document.getElementById("paint");return c.getContext("2d").getImageData(0,0,c.width,c.height).data.some((v,i)=>i%4===3&&v>0)})()'));
|
|
94
|
+
land.context.dispatchMouseEvent({type:'mouseReleased',x:x+80,y:y+40,button:'left',buttons:0});
|
|
95
|
+
await until(async()=>(await c.evaluate('document.getElementById("drag").textContent'))==='左鍵已放開');
|
|
96
|
+
expect(await c.evaluate('document.getElementById("log").textContent')).toContain('mousedown');
|
|
97
|
+
}finally{app?.close();land?.close();if(saved===undefined)delete process.env.XDG_RUNTIME_DIR;else process.env.XDG_RUNTIME_DIR=saved;rmSync(dir,{recursive:true,force:true})}
|
|
98
|
+
},30000);
|
|
99
|
+
|
|
100
|
+
test('pointer bursts preserve drawing points while batching raster work',async()=>{
|
|
101
|
+
const child=Bun.spawn([process.execPath,new URL('./fixtures/pointer-paint-batching.js',import.meta.url).pathname],{stdout:'pipe',stderr:'pipe'});
|
|
102
|
+
const [exit,stderr]=await Promise.all([child.exited,new Response(child.stderr).text()]);
|
|
103
|
+
expect(stderr).toBe('');expect(exit).toBe(0);
|
|
104
|
+
},15000);
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
import {test,expect} from 'bun:test';
|
|
2
|
+
import {RendererHost} from '../lib/renderer/host.js';
|
|
3
|
+
test('QML Canvas paints into HTML canvas and repaints after resize and property changes',async()=>{
|
|
4
|
+
const host=new RendererHost({timeout:20000});
|
|
5
|
+
try{
|
|
6
|
+
await host.start();
|
|
7
|
+
await host.call('loadDocument',{url:'data:text/x-qml,canvas',contentType:'text/x-qml',source:`import QtQuick
|
|
8
|
+
Canvas {id: c; width:16;height:16;property color ink:"red"
|
|
9
|
+
onInkChanged:requestPaint()
|
|
10
|
+
onPaint:{var ctx=getContext("2d");ctx.reset();ctx.fillStyle=ink;ctx.fillRect(0,0,width,height);globalThis.paints=(globalThis.paints||0)+1;}
|
|
11
|
+
Component.onCompleted:globalThis.canvasObject=c
|
|
12
|
+
}`});
|
|
13
|
+
await host.call('whenLoaded');
|
|
14
|
+
for(let i=0;i<100;i++){if(await host.call('evaluate','globalThis.paints>0'))break;await Bun.sleep(10);}
|
|
15
|
+
expect(await host.call('evaluate','Array.from(document.querySelector("canvas").getContext("2d").getImageData(8,8,1,1).data)')).toEqual([255,0,0,255]);
|
|
16
|
+
await host.call('evaluate','canvasObject.ink="blue";canvasObject.width=32;canvasObject.requestPaint();canvasObject.requestPaint()');
|
|
17
|
+
for(let i=0;i<100;i++){if(await host.call('evaluate','document.querySelector("canvas").width===32 && globalThis.paints>1'))break;await Bun.sleep(10);}
|
|
18
|
+
expect(await host.call('evaluate','Array.from(document.querySelector("canvas").getContext("2d").getImageData(24,8,1,1).data)')).toEqual([0,0,255,255]);
|
|
19
|
+
expect(await host.call('evaluate','canvasObject.available')).toBe(true);
|
|
20
|
+
}finally{host.close();}
|
|
21
|
+
},20000);
|