buninu-browser 0.2.3 → 0.2.4

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.
Files changed (105) hide show
  1. package/lib/cdp-server.js +47 -14
  2. package/lib/happy-dom/document-base.js +33 -0
  3. package/lib/happy-dom/parser.js +15 -6
  4. package/lib/happy-dom/selector-matcher.js +2 -2
  5. package/lib/happy-dom/selector-pseudo-adapter.js +19 -0
  6. package/lib/happy-dom/svg-attribute-adapter.js +50 -0
  7. package/lib/headless-shell.js +9 -6
  8. package/lib/input/hit-test.js +67 -19
  9. package/lib/layout/text-layout.js +213 -91
  10. package/lib/media/byte-pipe.js +2 -2
  11. package/lib/media/position-audio.js +10 -1
  12. package/lib/media/source-snapshots.js +126 -0
  13. package/lib/media/video-keyframe.js +17 -4
  14. package/lib/media/webm-stream.js +106 -0
  15. package/lib/network/network-service.js +268 -120
  16. package/lib/paint/display-list.js +8 -28
  17. package/lib/paint/frame-cache.js +25 -0
  18. package/lib/paint/frame.js +42 -13
  19. package/lib/paint/paragraph-text.js +24 -0
  20. package/lib/paint/raster-readback.js +41 -0
  21. package/lib/paint/rounded-rect.js +82 -0
  22. package/lib/paint/shared-frame-pool.js +105 -0
  23. package/lib/render-tree/index.js +38 -6
  24. package/lib/render-tree/svg.js +15 -7
  25. package/lib/renderer/bindings.js +233 -80
  26. package/lib/renderer/broadcast-channel.js +76 -0
  27. package/lib/renderer/canvas-2d.js +257 -14
  28. package/lib/renderer/canvas-text.js +120 -0
  29. package/lib/renderer/compositor.js +13 -11
  30. package/lib/renderer/custom-element-reactions.js +25 -1
  31. package/lib/renderer/font-loading.js +227 -0
  32. package/lib/renderer/host.js +28 -4
  33. package/lib/renderer/image-resources.js +65 -0
  34. package/lib/renderer/interfaces.js +28 -0
  35. package/lib/renderer/layout-observers.js +236 -0
  36. package/lib/renderer/main.js +30 -4
  37. package/lib/renderer/media-source.js +126 -55
  38. package/lib/renderer/page-realm.js +270 -98
  39. package/lib/renderer/page-renderer.js +647 -235
  40. package/lib/renderer/prepared-script-cache.js +50 -0
  41. package/lib/renderer/rendered-text.js +108 -0
  42. package/lib/renderer/rewrite-worker.js +4 -3
  43. package/lib/renderer/script-rewrite.js +13 -2
  44. package/lib/renderer/scroll-into-view.js +28 -0
  45. package/lib/renderer/slot-assignment.js +60 -0
  46. package/lib/renderer/video-presentation.js +23 -4
  47. package/lib/renderer/workers.js +2 -2
  48. package/lib/style/computed-style.js +611 -104
  49. package/lib/style/selector-traits.js +49 -5
  50. package/package.json +1 -1
  51. package/test/broadcast-channel.test.js +26 -0
  52. package/test/canvas-2d.test.js +83 -2
  53. package/test/canvas-paint.test.js +9 -0
  54. package/test/canvas-text.test.js +41 -0
  55. package/test/cdp-stream.test.js +56 -0
  56. package/test/computed-style-binding.test.js +151 -0
  57. package/test/computed-style.test.js +258 -1
  58. package/test/display-list.test.js +12 -0
  59. package/test/document-base.test.js +87 -0
  60. package/test/dom-platform.test.js +461 -0
  61. package/test/fetch-lifecycle.test.js +161 -0
  62. package/test/fixtures/canvas-paint.js +117 -0
  63. package/test/fixtures/canvas-text.js +42 -0
  64. package/test/fixtures/image-lifetime.js +90 -0
  65. package/test/fixtures/media-seek-snapshot.js +77 -0
  66. package/test/fixtures/mse-audio-chirp.webm +0 -0
  67. package/test/fixtures/mse-switch-large.mp4 +0 -0
  68. package/test/fixtures/mse-switch-small.mp4 +0 -0
  69. package/test/fixtures/renderer-memory.js +110 -0
  70. package/test/fixtures/summary-style.js +28 -0
  71. package/test/font-loading.test.js +97 -0
  72. package/test/grid.test.js +58 -0
  73. package/test/hit-test.test.js +21 -1
  74. package/test/image-lifetime.test.js +12 -0
  75. package/test/image-resources.test.js +94 -0
  76. package/test/local-media.test.js +84 -0
  77. package/test/media-seek-snapshot.test.js +11 -0
  78. package/test/media-source-config.test.js +98 -0
  79. package/test/media-source-webm-accounting.test.js +98 -0
  80. package/test/media-source-webm.test.js +108 -0
  81. package/test/navigation.test.js +1 -0
  82. package/test/network-service.test.js +388 -0
  83. package/test/page-scripts.test.js +1 -1
  84. package/test/paint-frame.test.js +103 -0
  85. package/test/paint-paragraph-cache.test.js +87 -0
  86. package/test/position-audio.test.js +24 -0
  87. package/test/positioning.test.js +22 -0
  88. package/test/prepared-script-cache.test.js +45 -0
  89. package/test/pulse-client.test.js +10 -3
  90. package/test/raster-readback.test.js +40 -0
  91. package/test/render-tree.test.js +28 -0
  92. package/test/renderer-memory.test.js +11 -0
  93. package/test/selector-traits.test.js +24 -0
  94. package/test/shared-frame-pool.test.js +63 -0
  95. package/test/style-sheets.test.js +51 -0
  96. package/test/svg.test.js +29 -0
  97. package/test/text-layout.test.js +106 -0
  98. package/test/video-keyframe.test.js +61 -0
  99. package/test/video-presentation.test.js +69 -0
  100. package/tools/browser-lab.js +78 -0
  101. package/tools/browser-soak.js +75 -0
  102. package/tools/network-reference.js +49 -0
  103. package/tools/process-memory.js +38 -0
  104. package/tools/script-benchmark.js +25 -0
  105. package/ai-debug.log +0 -5537
@@ -0,0 +1,117 @@
1
+ import { strict as assert } from "node:assert";
2
+ import { createPageRenderer, loadGraphics } from "../../lib/renderer/page-renderer.js";
3
+ const sleep = Bun.sleep, now = Date.now;
4
+ const { CanvasKit: kit } = await loadGraphics();
5
+ const redPng = Buffer.from("iVBORw0KGgoAAAANSUhEUgAAAAIAAAADCAIAAAA2iEnWAAAAEElEQVR4nGP4z8AARAwoFABE0AX7pM/egAAAAABJRU5ErkJggg==", "base64");
6
+ let frames = 0;
7
+ const corsRequests = [];
8
+ const renderer = await createPageRenderer({ onFrame: () => frames++,
9
+ fetchResource: async url => ({ ok: true, status: 200, url, body: redPng }),
10
+ pageFetch: async request => {
11
+ corsRequests.push(request);
12
+ if (request.url.includes("denied")) throw new TypeError("CORS disallowed");
13
+ return { status: 200, url: request.url, body: new Blob([redPng]).stream() };
14
+ },
15
+ });
16
+ async function pixel(x = 1, y = 1) {
17
+ const image = kit.MakeImageFromEncoded(Buffer.from(await renderer.screenshot(), "base64"));
18
+ try { return [...image.readPixels(x, y, { width: 1, height: 1, colorType: kit.ColorType.RGBA_8888,
19
+ alphaType: kit.AlphaType.Unpremul, colorSpace: kit.ColorSpace.SRGB })]; }
20
+ finally { image.delete(); }
21
+ }
22
+ try {
23
+ renderer.resize(400, 300, 1);
24
+ renderer.setScripting("host");
25
+ await renderer.loadDocument({ url: "https://canvas.test/", contentType: "text/html",
26
+ source: `<body style="margin:0"><canvas id=c style="display:block">fallback must not appear</canvas><span id=after>after</span><script>
27
+ window.canvas = document.getElementById('c'); window.ctx = canvas.getContext('2d');
28
+ ctx.fillStyle='red';ctx.fillRect(0,0,300,150);
29
+ </script>` });
30
+ await renderer.whenLoaded();
31
+ assert.deepEqual(renderer.inspect("canvas")[0].box, { x: 0, y: 0, width: 300, height: 150 });
32
+ assert.ok(renderer.inspect("#after")[0].box.y >= 150);
33
+ assert.deepEqual(await pixel(), [255, 0, 0, 255]);
34
+ assert.equal(await renderer.evaluate("ctx.canvas === canvas"), true);
35
+ assert.equal(await renderer.evaluate("ctx.Hd === undefined"), true, "native internals are not page APIs");
36
+ assert.equal((await renderer.evaluate("document.body.innerText")).includes("fallback"), false);
37
+ const initial = frames;
38
+ await renderer.evaluate("ctx.fillStyle='blue';ctx.fillRect(0,0,300,150)");
39
+ assert.deepEqual(await pixel(), [0, 0, 255, 255], "drawing alone invalidates paint");
40
+ assert.ok(frames > initial);
41
+ await renderer.evaluate("ctx.clearRect(0,0,300,150)");
42
+ assert.deepEqual(await pixel(), [255, 255, 255, 255], "transparent canvas shows page background");
43
+ await renderer.evaluate("ctx.fillStyle='green';ctx.fillRect(0,0,300,150);canvas.width=100");
44
+ assert.equal(renderer.inspect("canvas")[0].box.width, 100);
45
+ assert.deepEqual(await pixel(), [255, 255, 255, 255], "width reset clears displayed bitmap");
46
+ await renderer.evaluate("canvas.style.width='200px';canvas.style.height='100px';ctx.fillStyle='red';ctx.fillRect(0,0,50,150)");
47
+ assert.deepEqual(await pixel(99, 50), [255, 0, 0, 255]);
48
+ assert.deepEqual(await pixel(101, 50), [255, 255, 255, 255]);
49
+ await renderer.evaluate("canvas.remove()");
50
+ await renderer.evaluate("ctx.fillStyle='blue';ctx.fillRect(0,0,100,150);document.body.prepend(canvas)");
51
+ assert.deepEqual(await pixel(1, 1), [0, 0, 255, 255], "detached canvases keep their bitmap when reattached");
52
+ const exported = await renderer.evaluate("canvas.toDataURL('image/png')");
53
+ assert.deepEqual(await new Bun.Image(Buffer.from(exported.split(",")[1], "base64")).metadata(), { width: 100, height: 150, format: "png" });
54
+ assert.ok((await renderer.evaluate("canvas.toDataURL('unsupported/type')")).startsWith("data:image/png;base64,"));
55
+ assert.deepEqual(await renderer.evaluate("new Promise(resolve => canvas.toBlob(blob => resolve([blob.type.startsWith('image/'), blob.size > 0]), 'image/jpeg'))"), [true, true]);
56
+ await renderer.evaluate("window.copy=document.createElement('canvas');copy.width=copy.height=10;window.copyCtx=copy.getContext('2d');copyCtx.drawImage(canvas,0,0,10,10)");
57
+ assert.deepEqual(await renderer.evaluate("[...copyCtx.getImageData(1,1,1,1).data]"), [0, 0, 255, 255]);
58
+ await renderer.evaluate("window.img=document.createElement('img');img.style.display='none';img.src='https://foreign.test/red.png';document.body.appendChild(img)");
59
+ const deadline = now() + 5000;
60
+ while (!await renderer.evaluate("img.complete && img.naturalWidth === 2")) { assert.ok(now() < deadline); await sleep(10); }
61
+ await renderer.evaluate("ctx.drawImage(img,0,0,100,150)");
62
+ assert.deepEqual(await pixel(), [255, 0, 0, 255], "cross-origin images can be drawn");
63
+ assert.deepEqual(await renderer.evaluate("[()=>ctx.getImageData(0,0,1,1),()=>canvas.toDataURL(),()=>canvas.toBlob(()=>{})].map(fn=>{try{fn();return 'allowed'}catch(error){return error.name}})"), ["SecurityError", "SecurityError", "SecurityError"]);
64
+ await renderer.evaluate("copyCtx.drawImage(canvas,0,0,10,10)");
65
+ assert.equal(await renderer.evaluate("(()=>{try{copy.toDataURL();return 'allowed'}catch(error){return error.name}})()"), "SecurityError", "taint follows canvas copies");
66
+ await renderer.evaluate("canvas.width=100");
67
+ assert.ok((await renderer.evaluate("canvas.toDataURL()")).startsWith("data:image/png;base64,"), "bitmap reset restores origin-clean state");
68
+ await renderer.evaluate("img.crossOrigin='anonymous'");
69
+ while (!await renderer.evaluate("img.complete && img.naturalWidth === 2")) { assert.ok(now() < deadline); await sleep(10); }
70
+ assert.equal(corsRequests.length, 1, "changing crossorigin refetches with the CORS policy");
71
+ assert.equal(corsRequests[0].mode, "cors");
72
+ assert.equal(corsRequests[0].credentials, "same-origin");
73
+ await renderer.evaluate("ctx.drawImage(img,0,0,100,150)");
74
+ assert.deepEqual(await renderer.evaluate("[...ctx.getImageData(1,1,1,1).data]"), [255, 0, 0, 255], "CORS-approved foreign images keep the canvas clean");
75
+ await renderer.evaluate("img.crossOrigin='use-credentials'");
76
+ while (!await renderer.evaluate("img.complete && img.naturalWidth === 2")) { assert.ok(now() < deadline); await sleep(10); }
77
+ assert.equal(corsRequests.at(-1).credentials, "include");
78
+ await renderer.evaluate("img.src='https://foreign.test/denied.png'");
79
+ while (!await renderer.evaluate("img.complete")) { assert.ok(now() < deadline); await sleep(10); }
80
+ assert.equal(await renderer.evaluate("img.naturalWidth"), 0, "CORS-denied images cannot fall back to an unfiltered response");
81
+ await renderer.evaluate("canvas.width=0");
82
+ assert.equal(await renderer.evaluate("canvas.toDataURL()"), "data:,");
83
+ await renderer.evaluate("canvas.style.width='';canvas.style.height=''");
84
+ assert.deepEqual(renderer.inspect("canvas")[0].box, { x: 0, y: 0, width: 0, height: 150 });
85
+ assert.deepEqual(await renderer.evaluate(`(() => {
86
+ const c = document.createElement('canvas'), ctx = c.getContext('2d');
87
+ const fill = () => { ctx.fillStyle = 'red'; ctx.fillRect(0,0,1,1); };
88
+ const red = () => ctx.getImageData(0,0,1,1).data[0];
89
+ const results = [];
90
+ c.setAttribute('width', '20'); fill(); c.setAttribute('WIDTH', '20'); results.push(red());
91
+ fill(); c.setAttributeNS('urn:test', 'test:width', '7'); results.push(red(), c.width);
92
+ c.setAttributeNS(null, 'width', '30'); results.push(red(), c.width);
93
+ fill(); c.removeAttributeNS(null, 'width'); results.push(red(), c.width);
94
+ c.setAttribute('height', '10'); fill(); c.removeAttribute('height'); results.push(red(), c.height);
95
+ fill(); c.toggleAttribute('width', false); results.push(red());
96
+ c.toggleAttribute('width', true); results.push(red());
97
+ c.setAttribute('width','10'); fill(); c.getAttributeNode('width').value='10'; results.push(red());
98
+ const attr = c.getAttributeNode('width');
99
+ fill(); c.removeAttribute('width'); results.push(red(), c.width);
100
+ fill(); attr.value='15'; results.push(red(), c.width);
101
+ return results;
102
+ })()`), [0, 255, 20, 0, 30, 0, 300, 0, 150, 255, 0, 0, 0, 300, 255, 300], "all size-attribute APIs reset the bitmap; foreign/detached attributes and no-op removals do not");
103
+ await renderer.loadDocument({ url: "https://canvas.test/next", contentType: "text/html", source: "<body style='margin:0'>next</body>" });
104
+ await renderer.whenLoaded();
105
+ // Prepaint and animation are independent timers. A canvas draw retires its
106
+ // old native snapshot while a larger scrolled tile may still be queued.
107
+ await renderer.loadDocument({ url: "https://canvas.test/animation", contentType: "text/html",
108
+ source: `<body style="margin:0;height:5000px"><canvas id=a width=100 height=100 style="position:fixed;top:0;left:0"></canvas><script>
109
+ const ctx=document.getElementById('a').getContext('2d');let tick=0;
110
+ window.animation=setInterval(()=>{ctx.fillStyle=++tick%2?'red':'blue';ctx.fillRect(0,0,100,100)},17);
111
+ </script>` });
112
+ await renderer.whenLoaded();
113
+ for (let i = 0; i < 20; i++) { renderer.scroll(0, i % 2 ? -110 : 110); await sleep(31); }
114
+ await renderer.evaluate("clearInterval(animation)");
115
+ const animated = await pixel(10, 10);
116
+ assert.ok(animated[0] === 255 || animated[2] === 255);
117
+ } finally { renderer.dispose(); }
@@ -0,0 +1,42 @@
1
+ import { strict as assert } from "node:assert";
2
+ import { createPageRenderer } from "../../lib/renderer/page-renderer.js";
3
+ const renderer = await createPageRenderer({});
4
+ try {
5
+ renderer.resize(500, 200, 1);
6
+ renderer.setScripting("host");
7
+ await renderer.loadDocument({ url: "https://text.test/", contentType: "text/html",
8
+ source: `<body style="margin:0"><canvas width=500 height=200 id=c></canvas><script>
9
+ window.c=document.getElementById('c');window.ctx=c.getContext('2d');
10
+ window.ink=()=>{const data=ctx.getImageData(0,0,500,200).data;let left=500,right=-1,top=200,bottom=-1;
11
+ for(let y=0;y<200;y++)for(let x=0;x<500;x++)if(data[(y*500+x)*4+3]){left=Math.min(left,x);right=Math.max(right,x);top=Math.min(top,y);bottom=Math.max(bottom,y)}
12
+ return {left,right,top,bottom};};
13
+ </script>` });
14
+ await renderer.whenLoaded();
15
+ assert.equal(await renderer.evaluate("ctx.font"), "10px sans-serif");
16
+ await renderer.evaluate("ctx.font='32px sans-serif';window.width=ctx.measureText('Canvas text').width;ctx.fillText('Canvas text',10,50)");
17
+ assert.ok(await renderer.evaluate("width > 150 && width < 190"));
18
+ const left = await renderer.evaluate("ink()");
19
+ assert.ok(left.left >= 10 && left.right > 160 && left.bottom <= 51);
20
+ await renderer.evaluate("ctx.clearRect(0,0,500,200);ctx.textAlign='right';ctx.fillText('Canvas text',300,50)");
21
+ const right = await renderer.evaluate("ink()");
22
+ assert.ok(right.right >= 295 && right.right <= 300);
23
+ assert.ok(right.left > 125 && right.left < 150);
24
+ await renderer.evaluate("ctx.clearRect(0,0,500,200);ctx.textAlign='left';ctx.fillText('Canvas text',10,50,80)");
25
+ const scaled = await renderer.evaluate("ink()");
26
+ assert.ok(scaled.right <= 90 && scaled.right >= 85);
27
+ await renderer.evaluate("ctx.save();ctx.font='48px serif';ctx.textAlign='center';ctx.textBaseline='top';ctx.direction='rtl';ctx.restore()");
28
+ assert.deepEqual(await renderer.evaluate("[ctx.font,ctx.textAlign,ctx.textBaseline,ctx.direction]"), ["32px sans-serif", "left", "alphabetic", "inherit"]);
29
+ await renderer.evaluate("ctx.font='not a font';ctx.textAlign='invalid'");
30
+ assert.deepEqual(await renderer.evaluate("[ctx.font,ctx.textAlign]"), ["32px sans-serif", "left"]);
31
+ await renderer.evaluate("ctx.translate(12,18);ctx.scale(2,3)");
32
+ assert.deepEqual(await renderer.evaluate("const m=ctx.getTransform();[m.a,m.b,m.c,m.d,m.e,m.f]"), [2,0,0,3,12,18]);
33
+ await renderer.evaluate("c.width=500");
34
+ assert.deepEqual(await renderer.evaluate("[ctx.font,ctx.textAlign,ctx.textBaseline,ctx.direction]"), ["10px sans-serif", "start", "alphabetic", "inherit"]);
35
+ await renderer.evaluate("ctx.font='32px sans-serif';ctx.fillText('測試 🙂',10,50)");
36
+ assert.ok((await renderer.evaluate("ink()")).right > 60, "Unicode text uses browser fallback fonts");
37
+ await renderer.evaluate(`ctx.clearRect(0,0,500,200);window.gradient=ctx.createLinearGradient(0,0,150,0);
38
+ gradient.addColorStop(0,'red');gradient.addColorStop(1,'blue');ctx.strokeStyle=gradient;ctx.lineWidth=2;
39
+ ctx.strokeText('Gradient',10,50)`);
40
+ assert.ok((await renderer.evaluate("ink()")).right > 100, "shaped strokeText accepts a branded gradient without reading the native color-only getter");
41
+ assert.equal(await renderer.evaluate("ctx.strokeStyle===gradient && gradient.Td===undefined && gradient.pe===undefined"), true);
42
+ } finally { renderer.dispose(); }
@@ -0,0 +1,90 @@
1
+ // Host scripting is isolated from the test runner's globals.
2
+ import { strict as assert } from "node:assert";
3
+ import { createPageRenderer, loadGraphics } from "../../lib/renderer/page-renderer.js";
4
+ import { encodeImage } from "../../lib/paint/frame.js";
5
+ const sleep = Bun.sleep;
6
+ const now = Date.now;
7
+ const { CanvasKit } = await loadGraphics();
8
+ const images = new Map();
9
+ for (const [name, color] of [["red", [255, 0, 0, 255]], ["blue", [0, 0, 255, 255]]]) {
10
+ images.set(name, await encodeImage(new Uint8Array(Array.from({ length: 16 }, () => color).flat()), 4, 4));
11
+ }
12
+ const makeImage = CanvasKit.MakeImageFromEncoded;
13
+ let created = 0, alive = 0, frames = 0, fetched = 0;
14
+ const instrument = (image) => {
15
+ if (!image) return image;
16
+ created++; alive++;
17
+ const remove = image.delete.bind(image), clone = image.clone.bind(image);
18
+ image.delete = () => { alive--; remove(); };
19
+ image.clone = () => instrument(clone());
20
+ return image;
21
+ };
22
+ CanvasKit.MakeImageFromEncoded = bytes => instrument(makeImage(bytes));
23
+ let releaseLate;
24
+ const renderer = await createPageRenderer({
25
+ onFrame: () => { frames++; },
26
+ fetchResource: async url => {
27
+ fetched++;
28
+ if (url.includes("late")) await new Promise(resolve => { releaseLate = resolve; });
29
+ return { ok: true, status: 200, url, body: images.get(url.includes("blue") ? "blue" : "red") };
30
+ },
31
+ });
32
+ async function until(check) {
33
+ const end = now() + 5000;
34
+ while (!(await check())) { assert.ok(now() < end, "condition timed out"); await sleep(10); }
35
+ }
36
+ async function pixel() {
37
+ const image = makeImage(Buffer.from(await renderer.screenshot(), "base64"));
38
+ try { return [...image.readPixels(1, 1, { width: 1, height: 1, colorType: CanvasKit.ColorType.RGBA_8888,
39
+ alphaType: CanvasKit.AlphaType.Unpremul, colorSpace: CanvasKit.ColorSpace.SRGB })]; }
40
+ finally { image.delete(); }
41
+ }
42
+ try {
43
+ renderer.resize(400, 300, 1);
44
+ renderer.setScripting("host");
45
+ await renderer.loadDocument({ url: "https://images.test/", contentType: "text/html", source: '<body style="margin:0"></body>' });
46
+ await renderer.whenLoaded();
47
+ frames = 0;
48
+ await renderer.evaluate(`window.saved = []; for (let i=0;i<100;i++) {
49
+ const image=document.createElement('img'); image.width=image.height=20; image.src='/red.png';
50
+ document.body.appendChild(image); saved.push(image);
51
+ }`);
52
+ await until(async () => await renderer.evaluate("saved.every(image => image.complete && image.naturalWidth === 4)"));
53
+ await sleep(150);
54
+ assert.equal(fetched, 1, "one network/IPC request for duplicate in-flight images");
55
+ assert.equal(alive, 100, "independently-owned handles share one underlying image");
56
+ assert.ok(frames < 10, `one arrival burst must not paint 100 frames: ${frames}`);
57
+ assert.deepEqual(await pixel(), [255, 0, 0, 255]);
58
+ await renderer.evaluate("saved[0].src='/blue.png'");
59
+ await until(async () => await renderer.evaluate("saved[0].complete"));
60
+ assert.deepEqual(await pixel(), [0, 0, 255, 255], "same-size replacement invalidates the raster cache");
61
+ await renderer.evaluate("document.body.replaceChildren()");
62
+ assert.equal(alive, 0, "removed images release native handles even when script retains DOM nodes");
63
+ await renderer.evaluate("document.body.appendChild(saved[0])");
64
+ await until(async () => await renderer.evaluate("saved[0].complete"));
65
+ assert.equal(alive, 1, "reattaching a removed image reloads its resource");
66
+ assert.deepEqual(await pixel(), [0, 0, 255, 255]);
67
+ await renderer.evaluate("document.body.replaceChildren(); document.body.style.height='100px'");
68
+ for (let i=0;i<8;i++) {
69
+ await renderer.evaluate(`document.body.style.backgroundImage='url(/red-bg-${i}.png)'`);
70
+ await until(() => alive === 1);
71
+ await renderer.screenshot();
72
+ assert.equal(alive, 1, "unused CSS background images are released");
73
+ }
74
+ await renderer.evaluate("document.body.style.backgroundImage='none'; saved[0].src='/late.png'; document.body.appendChild(saved[0])");
75
+ await until(() => Boolean(releaseLate));
76
+ renderer.dispose();
77
+ const stopped = frames;
78
+ const beforeLate = created;
79
+ releaseLate();
80
+ await until(() => created > beforeLate);
81
+ await sleep(50);
82
+ assert.equal(alive, 0, "late resources cannot revive a disposed renderer");
83
+ assert.equal(frames, stopped);
84
+ renderer.dispose();
85
+ process.stdout.write("image lifetime integration passed\n");
86
+ } finally {
87
+ releaseLate?.();
88
+ renderer.dispose();
89
+ CanvasKit.MakeImageFromEncoded = makeImage;
90
+ }
@@ -0,0 +1,77 @@
1
+ import { strict as assert } from 'node:assert';
2
+ import { createPageRenderer } from '../../lib/renderer/page-renderer.js';
3
+ import { createBytePipe } from '../../lib/media/byte-pipe.js';
4
+ import wasmpeg from '../../vendor/wasmpeg/src/js/index.js';
5
+
6
+ // Isolated because host scripting installs page globals in this process.
7
+ const sleep = Bun.sleep;
8
+ const bytes = await Bun.file(new URL('./mse-switch-small.mp4', import.meta.url)).bytes();
9
+ const view = new DataView(bytes.buffer, bytes.byteOffset, bytes.byteLength);
10
+ let prefixEnd = 0;
11
+ for (let offset = 0; offset + 8 <= bytes.length;) {
12
+ const size = view.getUint32(offset);
13
+ assert.ok(size >= 8 && offset + size <= bytes.length);
14
+ const type = String.fromCharCode(...bytes.subarray(offset + 4, offset + 8));
15
+ if (type === 'mdat') { prefixEnd = offset + size; break; }
16
+ offset += size;
17
+ }
18
+ assert.ok(prefixEnd > 0 && prefixEnd < bytes.length);
19
+ await wasmpeg.load();
20
+ const expected = await wasmpeg.decode(bytes, { format: 'mp4' });
21
+ let expectedHash;
22
+ try {
23
+ expected.seek(4000);
24
+ expectedHash = Bun.hash(expected.nextFrame(64, 48));
25
+ } finally { expected.close(); }
26
+
27
+ const pipe = createBytePipe();
28
+ pipe.append(bytes.subarray(0, prefixEnd));
29
+ const openedSizes = [], drawnHashes = [], errors = [];
30
+ const renderer = await createPageRenderer({
31
+ fetchResource: async () => ({ ok: true, contentType: 'video/mp4', body: pipe }),
32
+ reportError: error => errors.push(String(error)),
33
+ videoDecoder: {
34
+ probe: (...args) => wasmpeg.probe(...args),
35
+ async decode(snapshot, options) {
36
+ openedSizes.push(snapshot.byteLength);
37
+ const decoder = await wasmpeg.decode(snapshot, options);
38
+ const nextFrame = decoder.nextFrame.bind(decoder);
39
+ decoder.nextFrame = (width, height) => {
40
+ const pixels = nextFrame(width, height);
41
+ // Seek probes are 32x18; these are frames actually drawn by the page.
42
+ if (pixels && width === 64 && height === 48) drawnHashes.push(Bun.hash(pixels));
43
+ return pixels;
44
+ };
45
+ return decoder;
46
+ },
47
+ },
48
+ });
49
+ try {
50
+ renderer.setScripting('host');
51
+ await renderer.loadDocument({ url: 'https://seek.test/', contentType: 'text/html',
52
+ source: '<video id="v" width="64" height="48" src="clip.mp4"></video>' });
53
+ const seek = seconds => renderer.evaluate(`new Promise(resolve => {
54
+ const video = document.getElementById('v');
55
+ video.addEventListener('seeked', () => resolve(true), { once: true });
56
+ video.currentTime = ${seconds};
57
+ })`);
58
+ await seek(0);
59
+ assert.equal(openedSizes.at(-1), prefixEnd);
60
+ assert.notEqual(drawnHashes.at(-1), expectedHash);
61
+ const firstHash = drawnHashes.at(-1);
62
+ const oldOpens = openedSizes.length;
63
+ pipe.append(bytes.subarray(prefixEnd));
64
+ pipe.finish();
65
+ for (let attempt = 0; attempt < 100 && await renderer.evaluate("document.getElementById('v').duration") < 6; attempt++) await sleep(10);
66
+ assert.equal(await renderer.evaluate("document.getElementById('v').duration"), 6);
67
+ await seek(5);
68
+ assert.ok(openedSizes.length > oldOpens, 'seeking beyond an old prefix opens the latest snapshot');
69
+ assert.equal(openedSizes.at(-1), bytes.length);
70
+ assert.equal(drawnHashes.at(-1), expectedHash, 'the seek draws the target keyframe, not the stale prefix');
71
+ const currentOpens = openedSizes.length;
72
+ await seek(1);
73
+ assert.equal(openedSizes.length, currentOpens, 'a current snapshot remains reusable for backward seeking');
74
+ assert.equal(drawnHashes.at(-1), firstHash);
75
+ assert.deepEqual(errors, []);
76
+ process.stdout.write('latest video snapshot seek passed\n');
77
+ } finally { renderer.dispose(); }
@@ -0,0 +1,110 @@
1
+ import { strict as assert } from 'node:assert';
2
+ import '../../vendor/ses/dist/ses.mjs';
3
+ import { createPageRenderer, loadGraphics } from '../../lib/renderer/page-renderer.js';
4
+
5
+ // Use the real WASM allocator: MakeSurface.delete() does not free its pixels.
6
+ // Each document needs an isolated realm: host scripting overwrites process
7
+ // globals and cannot be repeatedly installed for this lifetime stress test.
8
+ const { CanvasKit: kit } = await loadGraphics();
9
+ lockdown({ errorTaming: 'safe', overrideTaming: 'severe', consoleTaming: 'unsafe',
10
+ localeTaming: 'unsafe', stackFiltering: 'concise', reporting: 'none' });
11
+ const malloc = kit._malloc, free = kit._free, makeSurface = kit.MakeSurface;
12
+ const allocations = new Map(), surfaces = new Set(), images = new Set();
13
+ const errors = [];
14
+ let reads = 0, failAt = Infinity;
15
+ kit._malloc = size => { const pointer = malloc(size); allocations.set(pointer, size); return pointer; };
16
+ kit._free = pointer => { allocations.delete(pointer); return free(pointer); };
17
+ kit.MakeSurface = (...args) => {
18
+ const surface = makeSurface(...args);
19
+ if (!surface) return surface;
20
+ surfaces.add(surface);
21
+ const dispose = surface.dispose.bind(surface), snapshot = surface.makeImageSnapshot.bind(surface);
22
+ surface.dispose = () => { surfaces.delete(surface); return dispose(); };
23
+ surface.makeImageSnapshot = (...args) => {
24
+ const image = snapshot(...args), release = image.delete.bind(image), read = image.readPixels.bind(image);
25
+ images.add(image);
26
+ image.delete = () => { images.delete(image); return release(); };
27
+ image.readPixels = (...args) => {
28
+ if (++reads === failAt) throw new Error('intentional readback failure');
29
+ return read(...args);
30
+ };
31
+ return image;
32
+ };
33
+ return surface;
34
+ };
35
+ const liveBytes = () => [...allocations.values()].reduce((sum, size) => sum + size, 0);
36
+ let warmBytes;
37
+ try {
38
+ for (let cycle = 0; cycle < 8; cycle++) {
39
+ const renderer = await createPageRenderer({
40
+ fetchResource: async () => ({ ok: false, status: 404, body: new Uint8Array() }),
41
+ reportError: error => errors.push(String(error)),
42
+ });
43
+ try {
44
+ renderer.setScripting('ses');
45
+ renderer.resize(400, 300, 1);
46
+ await renderer.loadDocument({ url: 'https://memory.test/', contentType: 'text/html', source:
47
+ `<canvas id="c" width="200" height="150"></canvas><p>Renderer lifecycle</p><script>
48
+ window.c=document.getElementById('c');window.ctx=c.getContext('2d');
49
+ ctx.fillStyle='blue';ctx.fillRect(0,0,200,150);
50
+ </script>` });
51
+ await renderer.whenLoaded();
52
+ assert.equal(await renderer.evaluate("[...ctx.getImageData(1,1,1,1).data].join(',')"), '0,0,255,255');
53
+ for (let step = 0; step < 6; step++) {
54
+ renderer.resize(step % 2 ? 400 : 640, step % 2 ? 300 : 480, 1);
55
+ await renderer.evaluate(`c.width=${200 + step};ctx.fillStyle='red';ctx.fillRect(0,0,200,150)`);
56
+ assert.equal(await renderer.evaluate("[...ctx.getImageData(1,1,1,1).data].join(',')"), '255,0,0,255');
57
+ await renderer.screenshot({ format: step % 2 ? 'jpeg' : 'png' });
58
+ }
59
+ await renderer.loadDocument({ url: 'https://memory.test/next', contentType: 'text/html', source: '<p>Next document</p>' });
60
+ await renderer.whenLoaded();
61
+ await renderer.screenshot();
62
+ } finally { renderer.dispose(); }
63
+ assert.deepEqual(errors, [], 'every document actually executes its canvas script');
64
+ assert.equal(surfaces.size, 0, 'all software surface pixels are disposed');
65
+ assert.equal(images.size, 0, 'all snapshots are deleted');
66
+ warmBytes ??= liveBytes();
67
+ assert.equal(liveBytes(), warmBytes, 'native allocations do not accumulate across renderer lifetimes');
68
+ }
69
+
70
+ const presentations = [];
71
+ const renderer = await createPageRenderer({
72
+ fetchResource: async () => ({ ok: true, status: 200, body: new Uint8Array([1]) }),
73
+ reportError: error => errors.push(String(error)),
74
+ videoDecoder: {
75
+ probe: async () => ({ duration: 20, video: { width: 2, height: 2 } }),
76
+ decode: async () => ({ width: 2, height: 2, fps: 10, seek() {}, close() {}, nextFrame: () => new Uint8Array(16).fill(255) }),
77
+ },
78
+ onVideo(message) { presentations.push(message.type); },
79
+ });
80
+ try {
81
+ renderer.setScripting('ses');
82
+ await renderer.loadDocument({ url: 'https://memory.test/video', contentType: 'text/html',
83
+ source: '<video id="v" width="200" height="150" src="clip.mp4"></video>' });
84
+ await renderer.whenLoaded();
85
+ await renderer.evaluate("document.getElementById('v').play()");
86
+ for (let attempt = 0; attempt < 50 && !presentations.includes('video-open'); attempt++) {
87
+ await Bun.sleep(10);
88
+ await renderer.screenshot();
89
+ }
90
+ assert.ok(presentations.includes('video-open'), 'video uses the separate presentation layer');
91
+ assert.deepEqual(errors, []);
92
+ await renderer.screenshot();
93
+ // First read is the normal frame; second is the video overlay's base.
94
+ failAt = reads + 2;
95
+ assert.throws(() => renderer.resize(641, 481), /intentional readback failure/);
96
+ assert.equal(reads, failAt, 'the video overlay readback was exercised');
97
+ if (images.size !== 0) throw new Error(`failed overlay readback left ${images.size} snapshot(s) alive`);
98
+ } finally { failAt = Infinity; renderer.dispose(); }
99
+ assert.equal(surfaces.size, 0);
100
+ assert.equal(images.size, 0);
101
+ // Video MakeImage transfers its input allocation to C++; its eventual
102
+ // native free does not call the JS _free wrapper. Check the owned surfaces
103
+ // and snapshots here, rather than comparing that different allocation path
104
+ // with the canvas-only baseline above.
105
+ process.stdout.write('renderer memory lifecycle passed\n');
106
+ } finally {
107
+ kit._malloc = malloc;
108
+ kit._free = free;
109
+ kit.MakeSurface = makeSurface;
110
+ }
@@ -0,0 +1,28 @@
1
+ import { strict as assert } from "node:assert";
2
+ import { createPageRenderer } from "../../lib/renderer/page-renderer.js";
3
+
4
+ const renderer = await createPageRenderer({});
5
+ try {
6
+ renderer.setScripting("host");
7
+ await renderer.loadDocument({url:"https://page.test/",contentType:"text/html",status:200,
8
+ source:'<!doctype html><details><summary id="first">First</summary><summary id="second">Second</summary></details>'});
9
+ await renderer.whenLoaded();
10
+ const read = () => ["#first","#second"].map(selector => {
11
+ const style = renderer.inspect(selector,["display","listStyleType"])[0]?.style;
12
+ return style && [style.display,style.listStyleType];
13
+ });
14
+ const mutate = async source => {
15
+ await renderer.evaluate(`${source}; document.body.offsetHeight`);
16
+ };
17
+ const closed = [["list-item","disclosure-closed"],["block","disc"]];
18
+ assert.deepEqual(read(),closed);
19
+ await mutate("document.querySelector('details').open=true");
20
+ assert.deepEqual(read(),[["list-item","disclosure-open"],["block","disc"]]);
21
+ await mutate("document.querySelector('details').open=false");
22
+ assert.equal(await renderer.evaluate("document.querySelector('details').open"),false);
23
+ assert.deepEqual(read(),closed);
24
+ await mutate("document.querySelector('details').insertBefore(document.getElementById('second'),document.getElementById('first'))");
25
+ assert.deepEqual(read(),[["block","disc"],["list-item","disclosure-closed"]]);
26
+ await mutate("document.getElementById('second').remove()");
27
+ assert.deepEqual(read()[0],["list-item","disclosure-closed"]);
28
+ } finally { renderer.dispose(); }
@@ -0,0 +1,97 @@
1
+ import { expect, test } from "bun:test";
2
+ import { DocumentFonts, neededFontFaces } from "../lib/renderer/font-loading.js";
3
+ import { createPageRenderer, loadGraphics } from "../lib/renderer/page-renderer.js";
4
+
5
+ const face = (family, weight = 400, ranges = [[0, 0x10ffff]], extra = {}) => ({ family, weight: [weight, weight],
6
+ style: "normal", unicodeRanges: ranges, sources: [{ url: `https://font.test/${family}-${weight}.ttf` }], ...extra });
7
+ const run = (text, fontFamily, fontWeight = 400, fontStyle = "normal") => ({ text, style: { fontFamily, fontWeight, fontStyle } });
8
+
9
+ test("font selection requests only used families, styles, weights, and Unicode subsets", () => {
10
+ const latin = face("Example", 400, [[0, 127]]);
11
+ const cjk = face("Example", 400, [[0x4e00, 0x9fff]]);
12
+ const bold = face("Example", 700);
13
+ const italic = face("Example", 400, [[0, 127]], { style: "italic" });
14
+ const unused = face("Unused");
15
+ const all = [latin, cjk, bold, italic, unused];
16
+ expect([...neededFontFaces(all, [run("hello", ["Example", "Unused"])])]).toEqual([latin]);
17
+ expect([...neededFontFaces(all, [run("中文", ["Example"])])]).toEqual([cjk]);
18
+ expect([...neededFontFaces(all, [run("hello", ["Example"], 600)])]).toEqual([bold]);
19
+ expect([...neededFontFaces(all, [run("hello", ["Example"], 400, "italic")])]).toEqual([italic]);
20
+ expect([...neededFontFaces(all, [run("hello", ["system-ui", "Unused"])], new Set(["system-ui"]))]).toEqual([]);
21
+ const earlier = face("Later");
22
+ const later = face("Later");
23
+ expect([...neededFontFaces([earlier, later], [run("hello", ["Later"])])][0]).toBe(later);
24
+ });
25
+
26
+ test("document web fonts share builtin typefaces, use CORS, deduplicate sources, and honor Unicode aliases", async () => {
27
+ const { CanvasKit, builtinTypefaces, fontCollection } = await loadGraphics();
28
+ const bytes = await Bun.file(new URL("../usr/share/fonts/DejaVuSansMono.ttf", import.meta.url)).bytes();
29
+ const requests = [], errors = [];
30
+ let changed = 0;
31
+ const fonts = new DocumentFonts({ CanvasKit, builtins: builtinTypefaces, defaultCollection: fontCollection,
32
+ fetch: async request => { requests.push(request); return { status: 200, body: bytes }; },
33
+ changed: () => changed++, error: error => errors.push(error) });
34
+ const latin = face("Example", 400, [[0, 127]]);
35
+ const cjk = face("Example", 400, [[0x4e00, 0x9fff]]);
36
+ fonts.update([latin, cjk], [run("Hello中文", ["Example"])]);
37
+ await fonts.ready();
38
+ expect(requests).toHaveLength(1);
39
+ expect(requests[0]).toMatchObject({ mode: "cors", credentials: "same-origin", redirect: "follow" });
40
+ expect(changed).toBe(2);
41
+ expect(errors).toEqual([]);
42
+ expect(fonts.collection).not.toBe(fontCollection);
43
+ expect(fonts.resolve("Example", 400, "normal", 65)).toHaveLength(1);
44
+ expect(fonts.resolve("Example", 400, "normal", 0x4e00)).toHaveLength(1);
45
+ expect(fonts.resolve("Example", 400, "normal", 0x400)).toEqual([]);
46
+ fonts.dispose();
47
+ fonts.dispose();
48
+ expect(fonts.collection).toBe(fontCollection);
49
+ });
50
+
51
+ test("font sources try local/fetch alternatives and pending requests abort on document disposal", async () => {
52
+ const { CanvasKit, builtinTypefaces, fontCollection } = await loadGraphics();
53
+ let fetched = 0, aborted = 0;
54
+ const fonts = new DocumentFonts({ CanvasKit, builtins: builtinTypefaces, defaultCollection: fontCollection,
55
+ fetch: () => {
56
+ fetched++;
57
+ let reject;
58
+ const pending = new Promise((_, fail) => reject = fail);
59
+ pending.abort = () => { aborted++; reject(new Error("disposed")); };
60
+ return pending;
61
+ } });
62
+ const local = face("Local", 400, undefined, { sources: [{ local: "missing" }, { local: builtinTypefaces[0].family }] });
63
+ fonts.update([local], [run("hello", ["Local"])]);
64
+ await fonts.ready();
65
+ expect(fonts.resolve("Local")).toHaveLength(1);
66
+ expect(fetched).toBe(0);
67
+ const remote = face("Remote");
68
+ fonts.update([remote], [run("hello", ["Remote"])]);
69
+ expect(fetched).toBe(1);
70
+ fonts.dispose();
71
+ await fonts.ready();
72
+ expect(aborted).toBe(1);
73
+ });
74
+
75
+ test("loaded web font updates geometry, respects Unicode ranges, and resets on navigation", async () => {
76
+ const bytes = await Bun.file(new URL("../usr/share/fonts/DejaVuSansMono.ttf", import.meta.url)).bytes();
77
+ let finish;
78
+ const fontResponse = new Promise(resolve => finish = resolve);
79
+ let requests = 0;
80
+ const renderer = await createPageRenderer({ fetchResource: async url => ({ ok: false, status: 404, url, body: new Uint8Array() }),
81
+ pageFetch: () => { requests++; return fontResponse; } });
82
+ try {
83
+ const source = `<style>@font-face {font-family:TestMono;src:url(/font.ttf);unicode-range:U+0069} span{font-family:TestMono,sans-serif;font-size:30px}</style><span id=i>iiii</span><span id=m>mmmm</span>`;
84
+ await renderer.loadDocument({ url: "https://font.test/", status: 200, contentType: "text/html", source });
85
+ const before = renderer.inspect("span").map(item => item.box.width);
86
+ finish({ status: 200, body: bytes });
87
+ await renderer.whenLoaded();
88
+ const after = renderer.inspect("span").map(item => item.box.width);
89
+ expect(requests).toBe(1);
90
+ expect(after[0]).toBeGreaterThan(before[0] * 2);
91
+ expect(after[1]).toBe(before[1]);
92
+ await renderer.loadDocument({ url: "https://font.test/next", status: 200, contentType: "text/html",
93
+ source: `<span style="font-family:TestMono,sans-serif;font-size:30px">iiii</span>` });
94
+ await renderer.whenLoaded();
95
+ expect(renderer.inspect("span")[0].box.width).toBe(before[0]);
96
+ } finally { renderer.dispose(); }
97
+ });
package/test/grid.test.js CHANGED
@@ -220,6 +220,64 @@ test("implicit columns are created past the explicit grid", () => {
220
220
  expect(item(0)).toEqual({ left: 4, top: 0, width: 6, height: 1 });
221
221
  });
222
222
 
223
+ test("column auto-flow fills each explicit row before adding a column", () => {
224
+ const { items } = render(grid("grid-template-rows: 2px 3px; grid-auto-columns: 4px; grid-auto-flow: column", letters(5)));
225
+ expect(items().map((box) => [box.left, box.top, box.width, box.height])).toEqual([
226
+ [0, 0, 4, 2], [0, 2, 4, 3], [4, 0, 4, 2], [4, 2, 4, 3], [8, 0, 4, 2],
227
+ ]);
228
+ });
229
+
230
+ test("column auto-flow honors row spans and dense packing", () => {
231
+ const content = `<i style="grid-row: 2 / 4">a</i><i style="grid-row: span 2">b</i><i>c</i>`;
232
+ const sparse = render(grid("grid-template-rows: repeat(3, 2px); grid-auto-columns: 4px; grid-auto-flow: column", content));
233
+ const dense = render(grid("grid-template-rows: repeat(3, 2px); grid-auto-columns: 4px; grid-auto-flow: column dense", content));
234
+ expect(sparse.items().map((box) => [box.left, box.top])).toEqual([[0, 2], [4, 0], [4, 4]]);
235
+ expect(dense.items().map((box) => [box.left, box.top])).toEqual([[0, 2], [4, 0], [0, 0]]);
236
+ });
237
+
238
+ test("named areas place source-order-independent content and spanning cells", () => {
239
+ const { items } = render(grid('grid-template-columns: 4px 20px 6px; grid-template-rows: 2px 3px; grid-template-areas: "icon title meta" "icon body meta"',
240
+ '<i style="grid-area: title">title</i><i style="grid-area: meta">m</i><i style="grid-area: icon">i</i><i style="grid-area: body">body</i>'));
241
+ expect(items()).toEqual([
242
+ { left: 4, top: 0, width: 20, height: 2 }, { left: 24, top: 0, width: 6, height: 5 },
243
+ { left: 0, top: 0, width: 4, height: 5 }, { left: 4, top: 2, width: 20, height: 3 },
244
+ ]);
245
+ });
246
+
247
+ test("named areas create explicit tracks and implicit start/end line names", () => {
248
+ const { items } = render(grid('grid-template-areas: "a a ." "b b ."; grid-auto-columns: 4px; grid-auto-rows: 2px',
249
+ '<i style="grid-column: a; grid-row: 1">a</i><i style="grid-column: b-start / b-end; grid-row: b">b</i><i style="grid-column: -2 / -1; grid-row: 1">c</i>'));
250
+ expect(items()).toEqual([
251
+ { left: 0, top: 0, width: 8, height: 2 }, { left: 0, top: 2, width: 8, height: 2 },
252
+ { left: 8, top: 0, width: 4, height: 2 },
253
+ ]);
254
+ });
255
+
256
+ test("grid-template shorthand combines area strings, row sizes and columns", () => {
257
+ const { items } = render(grid('grid-template:"icon title"2px"icon body"3px/4px 20px',
258
+ '<i style="grid-area: title">title</i><i style="grid-area: icon">i</i><i style="grid-area: body">body</i>'));
259
+ expect(items()).toEqual([
260
+ { left: 4, top: 0, width: 20, height: 2 }, { left: 0, top: 0, width: 4, height: 5 },
261
+ { left: 4, top: 2, width: 20, height: 3 },
262
+ ]);
263
+ const defaults = render(grid('grid-template: "a b" / 4px 6px', '<i style="grid-area:b">b</i>'));
264
+ expect(defaults.item(0)).toEqual({ left: 4, top: 0, width: 6, height: 1 });
265
+ });
266
+
267
+ test("grid-template track shorthand resets old area placement", () => {
268
+ const { items } = render(grid('grid-template-areas:"b a";grid-template:2px / 4px 6px', letters(2)));
269
+ expect(items()).toEqual([
270
+ { left: 0, top: 0, width: 4, height: 2 }, { left: 4, top: 0, width: 6, height: 2 },
271
+ ]);
272
+ });
273
+
274
+ test("nonrectangular or unequal-width area declarations are discarded", () => {
275
+ for (const invalid of ['"a a" "a b"', '"a a" "b"', '"a ?"']) {
276
+ const { items } = render(grid(`grid-template-columns: 4px 6px; grid-template-areas: ${invalid}`, letters(2)));
277
+ expect(items().map((box) => box.width)).toEqual([4, 6]);
278
+ }
279
+ });
280
+
223
281
  // Gaps (css-align-3)
224
282
 
225
283
  test("column-gap separates the columns and comes off the free space", () => {