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
|
@@ -211,6 +211,7 @@ const INITIAL = Object.freeze({
|
|
|
211
211
|
visibility: "visible",
|
|
212
212
|
// Hit testing only (inherited): "none" lets clicks pass through.
|
|
213
213
|
pointerEvents: "auto",
|
|
214
|
+
userSelect: "auto",
|
|
214
215
|
// Breaking inside words (CSS Text 3 §5): only with these set.
|
|
215
216
|
overflowWrap: "normal",
|
|
216
217
|
wordBreak: "normal",
|
|
@@ -281,8 +282,8 @@ const UA_DECLARATIONS = new Map(Object.entries({
|
|
|
281
282
|
* fills them in when checked.
|
|
282
283
|
*/
|
|
283
284
|
const CONTROL_FONT = "font-size: 13.333px; font-family: sans-serif";
|
|
284
|
-
const FIELD = `${CONTROL_FONT}; border: 1px solid #767676; background-color: white`;
|
|
285
|
-
const BUTTON_LIKE = `${CONTROL_FONT}; border: 1px solid #767676; border-radius: 2px; padding: 1px 6px; background-color: #efefef; text-align: center`;
|
|
285
|
+
const FIELD = `${CONTROL_FONT}; color: black; border: 1px solid #767676; background-color: white`;
|
|
286
|
+
const BUTTON_LIKE = `${CONTROL_FONT}; color: black; border: 1px solid #767676; border-radius: 2px; padding: 1px 6px; background-color: #efefef; text-align: center`;
|
|
286
287
|
const UA_CONTROL_DECLARATIONS = Object.freeze({
|
|
287
288
|
textarea: parseDeclarations(`${FIELD}; font-family: monospace; padding: 2px; white-space: pre-wrap; overflow-wrap: break-word; overflow: auto`),
|
|
288
289
|
text: parseDeclarations(`${FIELD}; padding: 1px 2px; white-space: pre; overflow: hidden`),
|
|
@@ -1413,6 +1414,10 @@ function setProperty(style, name, value, parentStyle = null) {
|
|
|
1413
1414
|
if (["visible", "hidden", "collapse"].includes(value)) style.visibility = value === "collapse" ? "hidden" : value;
|
|
1414
1415
|
break;
|
|
1415
1416
|
// SVG-only values (visiblePainted, fill, ...) hit like auto for HTML boxes.
|
|
1417
|
+
case "user-select":
|
|
1418
|
+
case "-webkit-user-select":
|
|
1419
|
+
if (["auto", "none", "text", "all"].includes(value)) style.userSelect = value;
|
|
1420
|
+
break;
|
|
1416
1421
|
case "pointer-events":
|
|
1417
1422
|
if (/^[a-z]+$/i.test(value)) style.pointerEvents = value === "none" ? "none" : "auto";
|
|
1418
1423
|
break;
|
|
@@ -2517,6 +2522,7 @@ function cssValue(style, name) {
|
|
|
2517
2522
|
: style.lineHeight == null ? "normal" : `${style.lineHeight}px`,
|
|
2518
2523
|
"list-style-type": style.listStyleType,
|
|
2519
2524
|
"pointer-events": style.pointerEvents,
|
|
2525
|
+
"user-select": style.userSelect,
|
|
2520
2526
|
"text-align": style.textAlign,
|
|
2521
2527
|
"white-space": style.whiteSpace,
|
|
2522
2528
|
})[name] ?? style[name];
|
package/package.json
CHANGED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import {test, expect} from 'bun:test';
|
|
2
|
+
import {SharedAudioConnection} from '../lib/audio/shared-connection.js';
|
|
3
|
+
|
|
4
|
+
test('audio connection survives one renderer closing, ends with the last owner, and reconnects', async () => {
|
|
5
|
+
let opened = 0, closed = 0;
|
|
6
|
+
const pool = new SharedAudioConnection(async () => ({id:++opened, close(){closed++;}}));
|
|
7
|
+
const a={}, b={};
|
|
8
|
+
expect(await pool.acquire(a)).toBe(await pool.acquire(b));
|
|
9
|
+
pool.release(a); await Promise.resolve();
|
|
10
|
+
expect(closed).toBe(0);
|
|
11
|
+
pool.release(b); await Promise.resolve();
|
|
12
|
+
expect(closed).toBe(1);
|
|
13
|
+
expect((await pool.acquire(a)).id).toBe(2);
|
|
14
|
+
pool.release(a); await Promise.resolve();
|
|
15
|
+
expect(closed).toBe(2);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
test('closing during authentication does not close or clear a newer connection', async () => {
|
|
19
|
+
let resolveOld, closed = 0, calls = 0;
|
|
20
|
+
const pool = new SharedAudioConnection(() => ++calls===1
|
|
21
|
+
? new Promise(resolve=>{resolveOld=resolve;})
|
|
22
|
+
: {close(){closed++;}});
|
|
23
|
+
const a={},b={};
|
|
24
|
+
const old=pool.acquire(a); await Promise.resolve();
|
|
25
|
+
pool.release(a);
|
|
26
|
+
const current=await pool.acquire(b);
|
|
27
|
+
resolveOld({close(){closed++;}}); await old; await Promise.resolve();
|
|
28
|
+
expect(closed).toBe(1);
|
|
29
|
+
expect(await pool.acquire(b)).toBe(current);
|
|
30
|
+
pool.release(b); await Promise.resolve();
|
|
31
|
+
expect(closed).toBe(2);
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
test('late authentication failure does not invalidate a newer connection', async () => {
|
|
35
|
+
let rejectOld, calls=0;
|
|
36
|
+
const pool=new SharedAudioConnection(()=>++calls===1
|
|
37
|
+
? new Promise((_,reject)=>{rejectOld=reject;}) : {close(){}});
|
|
38
|
+
const a={},b={};
|
|
39
|
+
const old=pool.acquire(a); await Promise.resolve();
|
|
40
|
+
pool.release(a);
|
|
41
|
+
const current=await pool.acquire(b);
|
|
42
|
+
rejectOld(Error('old auth failed'));
|
|
43
|
+
await expect(old).rejects.toThrow('old auth failed');
|
|
44
|
+
expect(await pool.acquire(b)).toBe(current);
|
|
45
|
+
pool.release(b);
|
|
46
|
+
});
|
|
@@ -7,3 +7,11 @@ test("canvas bitmaps paint, update without DOM mutations, scale, reset and survi
|
|
|
7
7
|
expect(stderr).toBe("");
|
|
8
8
|
expect(exit).toBe(0);
|
|
9
9
|
}, 15_000);
|
|
10
|
+
|
|
11
|
+
test("canvas damage matches full repaint with transparency, clipping and overlapping content", async () => {
|
|
12
|
+
const child = Bun.spawn([process.execPath, new URL("./fixtures/canvas-damage.js", import.meta.url).pathname],
|
|
13
|
+
{ stdout: "pipe", stderr: "pipe" });
|
|
14
|
+
const [exit, stderr] = await Promise.all([child.exited, new Response(child.stderr).text()]);
|
|
15
|
+
expect(stderr).toBe("");
|
|
16
|
+
expect(exit).toBe(0);
|
|
17
|
+
}, 15_000);
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// Manual black-box reference check (no Chromium source code copied):
|
|
2
|
+
// bun test/compare-node-iterator.js /usr/local/bin/chromium-headless-shell
|
|
3
|
+
// Optional --snapshot writes Chromium's raw results for the regression test.
|
|
4
|
+
import { mkdtemp, rm } from 'node:fs/promises';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { pathToFileURL } from 'node:url';
|
|
8
|
+
import { RendererHost } from '../lib/renderer/host.js';
|
|
9
|
+
import { nodeIteratorCases } from './fixtures/node-iterator-cases.js';
|
|
10
|
+
|
|
11
|
+
const binary = process.argv[2] || Bun.which('chromium-headless-shell');
|
|
12
|
+
if (!binary) throw new Error('Pass the Chromium Headless Shell executable as the first argument');
|
|
13
|
+
const dir = await mkdtemp(join(tmpdir(), 'node-iterator-'));
|
|
14
|
+
const host = new RendererHost({timeout:20000,fetch:async()=>new Response('')});
|
|
15
|
+
let child;
|
|
16
|
+
try {
|
|
17
|
+
const expression = `JSON.stringify((${nodeIteratorCases})())`;
|
|
18
|
+
const path = join(dir, 'cases.html');
|
|
19
|
+
await Bun.write(path, `<body><script>document.body.textContent=${expression}</script>`);
|
|
20
|
+
child = Bun.spawn([binary,'--no-sandbox','--disable-gpu','--dump-dom',pathToFileURL(path).href],
|
|
21
|
+
{stdout:'pipe',stderr:'ignore'});
|
|
22
|
+
const html = await new Response(child.stdout).text();
|
|
23
|
+
if (await child.exited) throw new Error('Chromium failed');
|
|
24
|
+
const body = html.match(/<body>(.*?)<\/body>/s)?.[1];
|
|
25
|
+
const chromium = JSON.parse(body?.replaceAll('<','<').replaceAll('>','>').replaceAll('&','&'));
|
|
26
|
+
await host.call('loadDocument',{url:'https://iterator.test/',source:'<body></body>',contentType:'text/html',status:200});
|
|
27
|
+
await host.call('whenLoaded');
|
|
28
|
+
const browser = JSON.parse(await host.call('evaluate',expression));
|
|
29
|
+
let differences = 0;
|
|
30
|
+
for (let i = 0; i < chromium.length; i++) {
|
|
31
|
+
if (!Bun.deepEquals(browser[i], chromium[i])) { differences++;console.log({browser:browser[i],chromium:chromium[i]}); }
|
|
32
|
+
}
|
|
33
|
+
console.log(`${chromium.length} cases; ${differences} differences (see node-iterator.test.js for documented DOM-spec exceptions)`);
|
|
34
|
+
if (process.argv.includes('--snapshot')) {
|
|
35
|
+
await Bun.write(new URL('./fixtures/node-iterator-chromium.json',import.meta.url),
|
|
36
|
+
'[\n'+chromium.map(row=>' '+JSON.stringify(row)).join(',\n')+'\n]\n');
|
|
37
|
+
}
|
|
38
|
+
} finally {
|
|
39
|
+
child?.kill();
|
|
40
|
+
host.close();
|
|
41
|
+
await rm(dir,{recursive:true,force:true});
|
|
42
|
+
}
|
|
@@ -859,3 +859,15 @@ test("a linked style sheet's url()s resolve against the sheet", () => {
|
|
|
859
859
|
)).toBe(`a { background: url(https://site.test/css/img/a.png) } b { background: url("https://site.test/b.png") } c { background: url(data:image/png;base64,AA) }
|
|
860
860
|
d { background: url(https://cdn.test/d.png) } e { mask: url(#m) }`);
|
|
861
861
|
});
|
|
862
|
+
|
|
863
|
+
test("form controls default to readable dark text but author colors still win", () => {
|
|
864
|
+
const {document,window}=parseHTMLDocument(`<body style="color:lightblue"><textarea></textarea><input><button>Button</button><select><option>Option</option></select><textarea id="inherit" style="color:inherit"></textarea><input id="custom" style="color:red"></body>`);
|
|
865
|
+
try {
|
|
866
|
+
const styles=new StyleEngine().compute(document);
|
|
867
|
+
for(const e of document.querySelectorAll('textarea,input,button,select')) {
|
|
868
|
+
if(!e.id)expect(styles.get(e).color).toBe('rgba(0, 0, 0, 1)');
|
|
869
|
+
}
|
|
870
|
+
expect(styles.get(document.querySelector('#inherit')).color).toBe(styles.get(document.body).color);
|
|
871
|
+
expect(styles.get(document.querySelector('#custom')).color).toBe('rgba(255, 0, 0, 1)');
|
|
872
|
+
} finally {window.happyDOM.abort()}
|
|
873
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import {test,expect} from 'bun:test';
|
|
2
|
+
import {createBrowser} from '../lib/headless-shell.js';
|
|
3
|
+
test('drag selects document text and controls without stealing canceled mouse gestures',async()=>{
|
|
4
|
+
let copied='';const b=await createBrowser({spareRenderer:false,clipboard:{read:async()=>copied,write:async text=>{copied=text}}});
|
|
5
|
+
const c=b.context,run=s=>c.evaluate(s);
|
|
6
|
+
const down=(x,y)=>c.pointerButton(x,y,272,1),up=(x,y)=>c.pointerButton(x,y,272,0);
|
|
7
|
+
try{
|
|
8
|
+
await c.resize(500,300,1);await c.navigate('data:text/html,'+encodeURIComponent('<style>body{margin:0;font:20px monospace}p{margin:0;height:30px}input{font:20px monospace;padding:0;border:0}</style><p id=a>abcdefghij</p><p id=b>klmnopqrst</p><input value="abcdefghij">'));
|
|
9
|
+
await down(12,12);await c.move(60,12);await up(60,12);
|
|
10
|
+
expect(await run('getSelection().toString()')).toBe('bcde');
|
|
11
|
+
await c.press('c',{modifiers:2});for(let i=0;i<50&&!copied;i++)await Bun.sleep(10);expect(copied).toBe('bcde');
|
|
12
|
+
await down(60,12);await c.move(12,12);await up(12,12);expect(await run('getSelection().toString()')).toBe('bcde');
|
|
13
|
+
await down(12,12);await c.move(36,42);await up(36,42);expect(await run('getSelection().toString()')).toContain('klm');
|
|
14
|
+
await down(12,72);await c.move(60,72);await up(60,72);
|
|
15
|
+
expect(await run('(()=>{const e=document.querySelector("input");return [e.selectionStart,e.selectionEnd]})()')).toEqual([1,5]);
|
|
16
|
+
await run('document.getElementById("a").onmousedown=e=>e.preventDefault();getSelection().removeAllRanges()');
|
|
17
|
+
await down(12,12);await c.move(60,12);await up(60,12);expect(await run('getSelection().toString()')).toBe('');
|
|
18
|
+
await run('getSelection().selectAllChildren(document.getElementById("a"))');expect(await run('getSelection().toString()')).toBe('abcdefghij');
|
|
19
|
+
expect(await run('document.getSelection()===window.getSelection()')).toBe(true);
|
|
20
|
+
await run('document.getElementById("a").onmousedown=null;document.getElementById("a").style.userSelect="none";getSelection().removeAllRanges()');
|
|
21
|
+
await down(12,12);await c.move(60,12);await up(60,12);expect(await run('getSelection().toString()')).toBe('');
|
|
22
|
+
await run('document.getElementById("a").style.userSelect="text";getSelection().selectAllChildren(document.getElementById("a"))');
|
|
23
|
+
await run('globalThis.selectedRange=getSelection().getRangeAt(0);getSelection().removeAllRanges();getSelection().addRange(selectedRange)');
|
|
24
|
+
expect(await run('getSelection().toString()')).toBe('abcdefghij');
|
|
25
|
+
await Bun.write('/tmp/document-selection.png' ,Buffer.from(await c.screenshot({format:'png'}),'base64'));
|
|
26
|
+
}finally{b.close()}
|
|
27
|
+
},30000);
|
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
import {test,expect} from 'bun:test';
|
|
2
|
+
import {createBrowser} from '../lib/headless-shell.js';
|
|
3
|
+
import {CdpServer} from '../lib/cdp-server.js';
|
|
4
|
+
import {CDP} from '../tools/browser-lab.js';
|
|
5
|
+
import {attachInput} from '../frontends/wayland/input.js';
|
|
6
|
+
import {mkdtempSync,rmSync} from 'node:fs';
|
|
7
|
+
import {join} from 'node:path';
|
|
8
|
+
const html=`<body style="margin:0"><div id="box" style="width:200px;height:100px">Double click</div><script>globalThis.events=[];for(const type of ['mousedown','mouseup','click','dblclick'])document.getElementById('box').addEventListener(type,e=>{globalThis.events.push(type+':'+e.detail);e.preventDefault()})</script>`;
|
|
9
|
+
function pointer(context){
|
|
10
|
+
let seat,ptr;const errors=[];
|
|
11
|
+
const client={globals:new Map([['wl_seat',1]]),connection:{closed:false},bind:(n,v,cb)=>{seat=cb;return 1},object:(n,cb)=>{ptr=cb;return 2},request(){}};
|
|
12
|
+
attachInput(client,{surface:3,closed:false},context,e=>errors.push(e));seat('capabilities',[1]);ptr('enter',[1,3,20,20]);
|
|
13
|
+
return {event:ptr,errors,tap(time,x=20,y=20){ptr('motion',[time,x,y]);ptr('button',[1,time,272,1]);ptr('button',[2,time+30,272,0]);}};
|
|
14
|
+
}
|
|
15
|
+
const until=async(fn)=>{for(let i=0;i<100;i++){if(await fn())return;await Bun.sleep(20)}throw Error('input did not settle')};
|
|
16
|
+
|
|
17
|
+
test('CDP clickCount reaches DOM clicks and dblclick after cancelled click',async()=>{
|
|
18
|
+
const b=await createBrowser({spareRenderer:false});const server=CdpServer.create(b.context).listen(0);
|
|
19
|
+
const cdp=new CDP(`ws://127.0.0.1:${server.port}/devtools/browser/cdp-server`);
|
|
20
|
+
try{
|
|
21
|
+
await cdp.ready;
|
|
22
|
+
const {targetId}=await cdp.send('Target.createTarget',{url:'data:text/html,'+encodeURIComponent(html)});
|
|
23
|
+
const {sessionId}=await cdp.send('Target.attachToTarget',{targetId,flatten:true});cdp.sessionId=sessionId;
|
|
24
|
+
await b.context.navigate('data:text/html,'+encodeURIComponent(html));
|
|
25
|
+
for(const clickCount of [1,2,3])for(const type of ['mousePressed','mouseReleased'])await cdp.send('Input.dispatchMouseEvent',{type,x:20,y:20,button:'left',clickCount});
|
|
26
|
+
expect(await b.context.evaluate('events')).toEqual(['mousedown:1','mouseup:1','click:1','mousedown:2','mouseup:2','click:2','dblclick:2','mousedown:3','mouseup:3','click:3']);
|
|
27
|
+
}finally{cdp.close();server.stop(true);b.close()}
|
|
28
|
+
},30000);
|
|
29
|
+
|
|
30
|
+
test('Wayland clicks reach HTML and QML double-tap handlers',async()=>{
|
|
31
|
+
const b=await createBrowser({spareRenderer:false});const dir=mkdtempSync('/tmp/double-click-');
|
|
32
|
+
try{
|
|
33
|
+
await b.context.navigate('data:text/html,'+encodeURIComponent(html));
|
|
34
|
+
const p=pointer(b.context);p.tap(1000);p.tap(1180,28,24);
|
|
35
|
+
await until(async()=>(await b.context.evaluate('events.length'))===7);
|
|
36
|
+
expect(await b.context.evaluate('events.at(-1)')).toBe('dblclick:2');expect(p.errors).toEqual([]);
|
|
37
|
+
await Bun.write(join(dir,'main.qml'),`import QtQuick
|
|
38
|
+
Rectangle {width:200;height:100;color:"white";property int doubles:0
|
|
39
|
+
Text {id:status;text:"doubles="+parent.doubles}
|
|
40
|
+
TapHandler {onDoubleTapped:parent.doubles++}
|
|
41
|
+
}`);
|
|
42
|
+
await b.context.navigate(Bun.pathToFileURL(join(dir,'main.qml')).href);
|
|
43
|
+
const q=pointer(b.context);q.tap(2000,60,60);q.tap(2180,65,63);
|
|
44
|
+
await until(async()=>(await b.context.evaluate('document.getElementById("status").textContent'))==='doubles=1');
|
|
45
|
+
expect(q.errors).toEqual([]);
|
|
46
|
+
}finally{b.close();rmSync(dir,{recursive:true,force:true})}
|
|
47
|
+
},30000);
|
|
48
|
+
|
|
49
|
+
test('Wayland count resets on timeout, distance and leave and handles timestamp wrap',async()=>{
|
|
50
|
+
const counts=[];const p=pointer({click:(x,y,o)=>counts.push(o.clickCount),move(){}});
|
|
51
|
+
p.tap(0xffffff00);p.tap(0x20);p.tap(1000);p.tap(1100,100,100);
|
|
52
|
+
p.event('leave',[]);p.event('enter',[1,3,100,100]);p.tap(1150,100,100);
|
|
53
|
+
await Bun.sleep(0);expect(counts).toEqual([1,2,1,1,1]);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test('CDP and Wayland preserve non-primary buttons without primary activation',async()=>{
|
|
57
|
+
const b=await createBrowser({spareRenderer:false});const server=CdpServer.create(b.context).listen(0);
|
|
58
|
+
const cdp=new CDP(`ws://127.0.0.1:${server.port}/devtools/browser/cdp-server`);
|
|
59
|
+
const dir=mkdtempSync('/tmp/nonprimary-click-');
|
|
60
|
+
try {
|
|
61
|
+
await cdp.ready;
|
|
62
|
+
const url='data:text/html,'+encodeURIComponent(`<body style="margin:0"><a id="box" href="https://example.invalid/" style="display:block;width:200px;height:100px">link</a><script>globalThis.events=[];const box=document.getElementById("box");for(const t of ['mousedown','mouseup','click','auxclick','contextmenu'])box.addEventListener(t,e=>events.push([t,e.button,e.buttons]));box.onauxclick=e=>events.push(['property',e.button]);</script>`);
|
|
63
|
+
const {targetId}=await cdp.send('Target.createTarget',{url});
|
|
64
|
+
const {sessionId}=await cdp.send('Target.attachToTarget',{targetId,flatten:true});cdp.sessionId=sessionId;
|
|
65
|
+
await b.context.navigate(url);
|
|
66
|
+
for(const type of ['mousePressed','mouseReleased'])await cdp.send('Input.dispatchMouseEvent',{type,x:30,y:40,button:'right',clickCount:1});
|
|
67
|
+
expect(await b.context.evaluate('events')).toEqual([['mousedown',2,2],['contextmenu',2,2],['mouseup',2,0],['auxclick',2,0],['property',2]]);
|
|
68
|
+
expect(b.context.url).toBe(url);
|
|
69
|
+
await b.context.evaluate('events=[]');
|
|
70
|
+
const p=pointer(b.context);p.event('button',[1,1000,274,1]);p.event('button',[2,1030,274,0]);
|
|
71
|
+
await until(async()=>(await b.context.evaluate('events.length'))===4);
|
|
72
|
+
expect(await b.context.evaluate('events')).toEqual([['mousedown',1,4],['mouseup',1,0],['auxclick',1,0],['property',1]]);
|
|
73
|
+
await Bun.write(join(dir,'main.qml'),`import QtQuick
|
|
74
|
+
Rectangle {width:200;height:100;color:"white"
|
|
75
|
+
Text {id:status;text:"none"}
|
|
76
|
+
TapHandler {acceptedButtons:Qt.LeftButton | Qt.RightButton | Qt.MiddleButton;onTapped:(point,button)=>{status.text="button="+button}}
|
|
77
|
+
}`);
|
|
78
|
+
await b.context.navigate(Bun.pathToFileURL(join(dir,'main.qml')).href);
|
|
79
|
+
const q=pointer(b.context);
|
|
80
|
+
for(const [code,expected] of [[273,2],[274,4]]){
|
|
81
|
+
q.event('button',[1,2000,code,1]);q.event('button',[2,2030,code,0]);
|
|
82
|
+
await until(async()=>(await b.context.evaluate('document.getElementById("status").textContent'))==='button='+expected);
|
|
83
|
+
}
|
|
84
|
+
}finally{cdp.close();server.stop(true);b.close();rmSync(dir,{recursive:true,force:true})}
|
|
85
|
+
},30000);
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import {strict as assert} from 'node:assert';
|
|
2
|
+
import {createPageRenderer,loadGraphics} from '../../lib/renderer/page-renderer.js';
|
|
3
|
+
const {CanvasKit:kit}=await loadGraphics();
|
|
4
|
+
const traces=[];
|
|
5
|
+
const r=await createPageRenderer({onFrame:()=>{},trace:s=>traces.push(s)});
|
|
6
|
+
async function pixels(){
|
|
7
|
+
const image=kit.MakeImageFromEncoded(Buffer.from(await r.screenshot(),'base64'));
|
|
8
|
+
try{return image.readPixels(0,0,{width:image.width(),height:image.height(),colorType:kit.ColorType.RGBA_8888,alphaType:kit.AlphaType.Unpremul,colorSpace:kit.ColorSpace.SRGB})}finally{image.delete()}
|
|
9
|
+
}
|
|
10
|
+
try{
|
|
11
|
+
r.resize(400,300,1);r.setScripting('host');
|
|
12
|
+
await r.loadDocument({url:'https://damage.test/',contentType:'text/html',source:`
|
|
13
|
+
<!doctype html><html><head><style>body{margin:0;background:#abc}.clip{position:absolute;left:30px;top:20px;width:90px;height:80px;overflow:hidden;border-radius:15px;background:yellow}canvas{width:120px;height:100px;opacity:.6}.over{position:absolute;left:50px;top:40px;width:30px;height:30px;background:green}</style></head><body>
|
|
14
|
+
<div class=clip><canvas width=120 height=100></canvas></div><div class=over></div><p style="position:absolute;top:160px">Preserve text outside canvas</p>
|
|
15
|
+
<script>globalThis.ctx=document.querySelector('canvas').getContext('2d');</script></body></html>`});
|
|
16
|
+
await r.whenLoaded();
|
|
17
|
+
await r.evaluate("ctx.fillRect(0,0,1,1)");
|
|
18
|
+
await pixels();await Bun.sleep(100);
|
|
19
|
+
for(const command of ["ctx.fillStyle='red';ctx.fillRect(0,0,120,100)","ctx.clearRect(0,0,120,100);ctx.fillStyle='blue';ctx.fillRect(15,15,20,20)","ctx.clearRect(0,0,120,100)"]){
|
|
20
|
+
traces.length=0;
|
|
21
|
+
await r.evaluate(command);
|
|
22
|
+
const partial=await pixels();
|
|
23
|
+
assert.ok(traces.some(s=>s.includes('canvas damage')),'exercise the partial paint path');
|
|
24
|
+
r.resize(401,300,1);r.resize(400,300,1);
|
|
25
|
+
assert.deepEqual(await pixels(),partial,'partial pixels must match a full repaint, including transparency, clipping and occlusion');
|
|
26
|
+
}
|
|
27
|
+
// Geometry/transform changes must fall back, then remain pixel-correct.
|
|
28
|
+
traces.length=0;
|
|
29
|
+
await r.evaluate("document.querySelector('.clip').style.transform='scale(1.2)';ctx.fillRect(0,0,80,80)");
|
|
30
|
+
const transformed=await pixels();
|
|
31
|
+
assert.ok(!traces.some(s=>s.includes('canvas damage')));
|
|
32
|
+
r.resize(401,300,1);r.resize(400,300,1);
|
|
33
|
+
assert.deepEqual(await pixels(),transformed);
|
|
34
|
+
traces.length=0;
|
|
35
|
+
await r.evaluate("ctx.clearRect(0,0,120,100)");
|
|
36
|
+
const cleared=await pixels();
|
|
37
|
+
assert.ok(!traces.some(s=>s.includes('canvas damage')),'transformed canvas stays on full paint');
|
|
38
|
+
r.resize(401,300,1);r.resize(400,300,1);
|
|
39
|
+
assert.deepEqual(await pixels(),cleared);
|
|
40
|
+
}finally{r.dispose()}
|
|
@@ -7,6 +7,7 @@ process.on("message", async (message) => {
|
|
|
7
7
|
if (message?.type !== "call") return;
|
|
8
8
|
const [argument] = message.params;
|
|
9
9
|
switch (message.method) {
|
|
10
|
+
case "configuration": return process.send({ type: "result", id: message.id, result: { args: process.argv, secret: process.env.BUNINU_TEST_PRIVATE_ENV } });
|
|
10
11
|
case "echo": return process.send({ type: "result", id: message.id, result: { pid: process.pid, argument } });
|
|
11
12
|
case "hang": for (;;) {}
|
|
12
13
|
case "fail": return process.send({ type: "result", id: message.id, error: "Error: page crashed\n at somewhere" });
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import {strict as assert} from 'node:assert';
|
|
2
|
+
import {createPageRenderer,loadGraphics} from '../../lib/renderer/page-renderer.js';
|
|
3
|
+
const {CanvasKit}=await loadGraphics();
|
|
4
|
+
const original=CanvasKit.ParagraphBuilder.MakeFromFontCollection;
|
|
5
|
+
let checked=0;const missing=[];
|
|
6
|
+
CanvasKit.ParagraphBuilder.MakeFromFontCollection=function(...args){
|
|
7
|
+
const builder=original.apply(this,args),add=builder.addText.bind(builder),build=builder.build.bind(builder);
|
|
8
|
+
let text='';
|
|
9
|
+
builder.addText=value=>{text+=value;return add(value)};
|
|
10
|
+
builder.build=()=>{
|
|
11
|
+
const paragraph=build(),layout=paragraph.layout.bind(paragraph);
|
|
12
|
+
paragraph.layout=width=>{
|
|
13
|
+
layout(width);
|
|
14
|
+
if(width<10000 && text.trim()){
|
|
15
|
+
const last=text.trimEnd().length-1;
|
|
16
|
+
const rects=paragraph.getRectsForRange(last,last+1,CanvasKit.RectHeightStyle.Max,CanvasKit.RectWidthStyle.Tight);
|
|
17
|
+
if(!rects.length)missing.push({text,width});
|
|
18
|
+
checked++;
|
|
19
|
+
}
|
|
20
|
+
};
|
|
21
|
+
return paragraph;
|
|
22
|
+
};
|
|
23
|
+
return builder;
|
|
24
|
+
};
|
|
25
|
+
const r=await createPageRenderer({});
|
|
26
|
+
try{
|
|
27
|
+
for(const scale of [1,1.125,1.25,1.5,2]){
|
|
28
|
+
r.resize(900,300,scale);
|
|
29
|
+
await r.loadDocument({url:'https://text.test/',contentType:'text/html',source:`<body style="margin:0">
|
|
30
|
+
<div style="display:flex;gap:24px;font-size:15.2px"><span>Contact</span><span>Ecosystem</span><span>Contributors</span></div>
|
|
31
|
+
<div style="display:flex;gap:24px;font-size:14.4px"><span>Buninu Linux</span><span>Long navigation label</span></div>
|
|
32
|
+
<button style="font:14.4px monospace">Show email address</button>
|
|
33
|
+
</body>`});
|
|
34
|
+
await r.whenLoaded();await r.screenshot();
|
|
35
|
+
}
|
|
36
|
+
assert.deepEqual(missing,[],'All painted lines must retain their final character');
|
|
37
|
+
assert.ok(checked>=30,'All scales must exercise painted paragraphs');
|
|
38
|
+
}finally{r.dispose();CanvasKit.ParagraphBuilder.MakeFromFontCollection=original}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
// Runs unchanged in the renderer and Chromium; keep this function standalone.
|
|
2
|
+
export function nodeIteratorCases() {
|
|
3
|
+
const results = [];
|
|
4
|
+
const label = n => n ? n.id || n.nodeName : null;
|
|
5
|
+
for (const tag of ['div', 'form', 'select'])
|
|
6
|
+
for (const operation of ['remove', 'replace', 'replaceWith', 'self', 'sibling', 'fragment', 'descendant', 'multiple'])
|
|
7
|
+
for (const position of [0, 1, 2, 3]) for (const before of [false, true]) {
|
|
8
|
+
const root = document.createElement(tag);root.id = 'root';
|
|
9
|
+
for (const id of ['a', 'b']) {
|
|
10
|
+
const node = document.createElement('option');node.id = id;root.append(node);
|
|
11
|
+
}
|
|
12
|
+
const nested = document.createElement('span');nested.id = 'nested';root.firstChild.append(nested);
|
|
13
|
+
const it = document.createNodeIterator(root, 1);
|
|
14
|
+
for (let i = 0; i <= position; i++) it.nextNode();
|
|
15
|
+
if (before) it.previousNode();
|
|
16
|
+
const old = root.firstChild, x = document.createElement('em');x.id = 'x';
|
|
17
|
+
if (operation === 'remove') old.remove();
|
|
18
|
+
if (operation === 'replace') root.replaceChild(x, old);
|
|
19
|
+
if (operation === 'replaceWith') old.replaceWith(x);
|
|
20
|
+
if (operation === 'self') root.replaceChild(old, old);
|
|
21
|
+
if (operation === 'sibling') root.replaceChild(root.lastChild, old);
|
|
22
|
+
if (operation === 'descendant') root.replaceChild(nested, old);
|
|
23
|
+
if (operation === 'fragment') {
|
|
24
|
+
const fragment = document.createDocumentFragment();fragment.append(x);
|
|
25
|
+
const y = document.createElement('em');y.id = 'y';fragment.append(y);
|
|
26
|
+
root.replaceChild(fragment, old);
|
|
27
|
+
}
|
|
28
|
+
if (operation === 'multiple') old.replaceWith(x, old, root.lastChild);
|
|
29
|
+
results.push([tag, operation, position, before, label(it.referenceNode),
|
|
30
|
+
it.pointerBeforeReferenceNode, label(it.nextNode()), label(it.previousNode()),
|
|
31
|
+
[...root.childNodes].map(label).join(',')]);
|
|
32
|
+
}
|
|
33
|
+
for (const whatToShow of [0, 1, -1, 4294967297, NaN, Infinity, '1', null, 1n, Object(1n), Symbol('x')]) {
|
|
34
|
+
try { results.push(['mask', String(whatToShow), document.createNodeIterator(document.body, whatToShow).whatToShow]); }
|
|
35
|
+
catch (e) { results.push(['mask', String(whatToShow), e.name]); }
|
|
36
|
+
}
|
|
37
|
+
for (const verdict of [1, 65537, -65535, NaN, Infinity, '1', null, 1n, Object(1n), Symbol('x')]) {
|
|
38
|
+
try { results.push(['filter', String(verdict), !!document.createNodeIterator(document.body, 1, () => verdict).nextNode()]); }
|
|
39
|
+
catch (e) { results.push(['filter', String(verdict), e.name]); }
|
|
40
|
+
}
|
|
41
|
+
// Filtering and root types: rejection must not prune descendants.
|
|
42
|
+
for (const rootKind of ['element', 'fragment', 'text', 'comment', 'doctype'])
|
|
43
|
+
for (const mask of [0, 1, 4, 128, 0xffffffff]) for (const mode of ['accept', 'reject', 'skip', 'alternating']) {
|
|
44
|
+
const root = rootKind === 'fragment' ? document.createDocumentFragment()
|
|
45
|
+
: rootKind === 'text' ? document.createTextNode('text')
|
|
46
|
+
: rootKind === 'comment' ? document.createComment('comment')
|
|
47
|
+
: rootKind === 'doctype' ? document.implementation.createDocumentType('html', '', '') : document.createElement('div');
|
|
48
|
+
if (rootKind === 'element' || rootKind === 'fragment') {
|
|
49
|
+
const child = document.createElement('b');child.append('text');root.append(child, document.createComment('comment'));
|
|
50
|
+
}
|
|
51
|
+
const it = document.createNodeIterator(root, mask, n => mode === 'accept' ? 1
|
|
52
|
+
: mode === 'reject' ? 2 : mode === 'skip' ? 3 : n.nodeType === 1 ? 2 : 1);
|
|
53
|
+
const forward = [], backward = [];
|
|
54
|
+
for (let n; (n = it.nextNode());) forward.push(n.nodeType);
|
|
55
|
+
for (let n; (n = it.previousNode());) backward.push(n.nodeType);
|
|
56
|
+
results.push(['walk', rootKind, mask, mode, forward.join(','), backward.join(',')]);
|
|
57
|
+
}
|
|
58
|
+
return results;
|
|
59
|
+
}
|