Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
230 changes: 204 additions & 26 deletions packages/core/src/htmlInCanvasDefects.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import {
chromeMajor,
createDefectScan,
findCaptureDefect,
scanBackfaceDefect,
type DefectEnv,
Expand Down Expand Up @@ -30,6 +31,21 @@ const env = (over: Partial<DefectEnv> = {}): DefectEnv => ({

const code = (root: HTMLElement, e: DefectEnv) => findCaptureDefect(root, e)?.code ?? null;

/** Every Chrome the render and preview browsers can be, and an unknown one. */
const VERSIONS = [152, 155, 156, 157, 158, null] as const;

/** The defect found with the page's user agent set to `major`. */
function codeOn(major: number | null, root: HTMLElement, e: DefectEnv): string | null {
const spy = vi
.spyOn(window.navigator, "userAgent", "get")
.mockReturnValue(ua(major).navigator.userAgent);
try {
return code(root, e);
} finally {
spy.mockRestore();
}
}

afterEach(() => {
document.body.innerHTML = document.head.innerHTML = "";
vi.unstubAllGlobals();
Expand Down Expand Up @@ -57,67 +73,183 @@ describe("nested-capture-canvas", () => {
it("is found on every version, in either API form", () => {
for (const attr of ["layoutsubtree", 'content="drawable"']) {
const root = mount(`<canvas ${attr}></canvas>`);
for (const major of [152, 158, null]) {
expect(code(root, env({ win: ua(major) }))).toBe("nested-capture-canvas");
}
for (const major of VERSIONS)
expect(codeOn(major, root, env())).toBe("nested-capture-canvas");
}
});
});

describe("accelerated-canvas", () => {
const gl = (root: HTMLElement) => env({ isAccelerated: (c) => root.contains(c) });

it("applies below 157 and when the version is unknown, not from 157", () => {
it("applies on every version, 157 and 158 included", () => {
const root = mount("<canvas></canvas>");
for (const major of [152, 155, null]) {
expect(code(root, { ...gl(root), win: ua(major) })).toBe("accelerated-canvas");
for (const major of VERSIONS) {
expect(codeOn(major, root, gl(root))).toBe("accelerated-canvas");
}
});

it("finds a WebGL canvas outside the root, where a render's shader compositor puts it", () => {
const root = mount("");
const outside = document.createElement("canvas");
document.body.append(outside);
for (const major of VERSIONS) {
expect(codeOn(major, root, env({ isAccelerated: (c) => c === outside }))).toBe(
"accelerated-canvas",
);
}
for (const major of [157, 158]) expect(code(root, { ...gl(root), win: ua(major) })).toBeNull();
});

it("ignores a canvas the page did not give a WebGL context", () => {
expect(code(mount("<canvas></canvas>"), env({ win: ua(152) }))).toBeNull();
expect(code(mount("<canvas></canvas>"), env())).toBeNull();
});
});

describe("iframe-in-root", () => {
it("applies on every version: a reload cannot be undone", () => {
const root = mount('<div><iframe src="about:blank"></iframe></div>');
for (const major of [152, 157, 158])
expect(code(root, env({ win: ua(major) }))).toBe("iframe-in-root");
for (const major of VERSIONS) expect(codeOn(major, root, env())).toBe("iframe-in-root");
});

it("is not reported for a composition without an iframe", () => {
expect(code(mount("<div><img></div>"), env())).toBeNull();
});

it("also covers <object>, <embed> and defined custom elements, which are re-initialised too", () => {
customElements.define("x-defect-card", class extends HTMLElement {});
for (const html of ["<object></object>", "<embed>", "<x-defect-card></x-defect-card>"])
expect(findCaptureDefect(mount(html), env())).toMatchObject({
code: "iframe-in-root",
reason: expect.stringContaining("re-initialised when blur wraps the root"),
});
expect(code(mount("<x-defect-undefined></x-defect-undefined>"), env())).toBeNull();
});

it("is found by the timeline scan at any step, and says where it first appeared", () => {
const root = mount("");
const scan = createDefectScan(root, () => false);
scan.sample(0, 10, 0);
root.append(document.createElement("iframe"));
scan.sample(3, 10, 1.5);
expect(scan.result()).toMatchObject({ code: "iframe-in-root" });
expect(scan.result()?.reason).toContain("t=1.5s");
});
});

describe("facesNow: the scan's candidates, read at the current time", () => {
it("sees a hidden face flip into 3D after the scan, without rescanning", () => {
const root = mount('<div id="card" style="backface-visibility: hidden"></div>');
const scan = createDefectScan(root, () => false);
scan.sample(0, 10, 0);
expect(scan.facesNow()).toBe(false);
const card = document.getElementById("card");
if (card) card.style.transform = "rotateY(100deg)";
expect(scan.facesNow()).toBe(true);
if (card) card.style.transform = "none";
expect(scan.facesNow()).toBe(false);
});

it("takes in a face an animation keys, and keeps it once the animation is gone", () => {
const root = mount('<div style="transform-style: preserve-3d"><div id="f"></div></div>');
const face = root.querySelector<HTMLElement>("#f") ?? root;
const scan = createDefectScan(root, () => false);
animating(face, [{ backfaceVisibility: "hidden" }]);
scan.sample(0, 10, 0);
expect(scan.result()).toBeNull();
expect(scan.facesNow()).toBe(false);
// The rule comes after the scan read the sheets: only the animation made the face a candidate.
document.head.innerHTML = "<style>.hide{backface-visibility:hidden}</style>";
face.className = "hide";
expect(scan.facesNow()).toBe(true);
Object.assign(document, { getAnimations: () => [] });
scan.sample(10, 10, 1);
expect(scan.result()?.code).toBe("backface-visibility");
});

it("knows only the elements the scan's last face check found", () => {
const root = mount("");
const scan = createDefectScan(root, () => false);
scan.sample(0, 10, 0);
const late = document.createElement("div");
late.setAttribute("style", "backface-visibility: hidden; transform: rotateY(100deg)");
root.append(late);
expect(scan.facesNow()).toBe(false);
});
});

describe("root-transform", () => {
it("applies below 157 and when the version is unknown, not from 157", () => {
it("applies on every version, 157 and 158 included", () => {
const root = mount("", "transform: scale(0.5)");
expect(code(root, env({ win: ua(152) }))).toBe("root-transform");
expect(code(root, env({ win: ua(156) }))).toBe("root-transform");
expect(code(root, env({ win: ua(null) }))).toBe("root-transform");
expect(code(root, env({ win: ua(157) }))).toBeNull();
expect(code(root, env({ win: ua(158) }))).toBeNull();
for (const major of VERSIONS) expect(codeOn(major, root, env())).toBe("root-transform");
});

it("ignores a root without a transform", () => {
expect(code(mount(""), env({ win: ua(152) }))).toBeNull();
expect(code(mount(""), env())).toBeNull();
});

it("ignores an identity transform, 2D or 3D, which changes nothing on screen", () => {
for (const t of [
"matrix(1, 0, 0, 1, 0, 0)",
"matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1)",
]) {
for (const major of VERSIONS)
expect(codeOn(major, mount("", `transform: ${t}`), env())).toBeNull();
}
});

it("reports a real translate, scale or rotate, 2D or 3D", () => {
for (const t of [
"translateX(3px)",
"scale(1.01)",
"rotate(1deg)",
"matrix(1, 0, 0, 1, 0, 2)",
"matrix3d(1, 0, 0, 0, 0, 1, 0, 0, 0, 0, 1, 0, 0, 0, 5, 1)",
]) {
expect(code(mount("", `transform: ${t}`), env())).toBe("root-transform");
}
});
});

describe("root individual transform properties and blend mode", () => {
it("reports a root moved by scale, rotate or translate, not an identity one", () => {
for (const t of ["scale: 0.5", "rotate: 3deg", "translate: 5px 0"])
expect(code(mount("", t), env())).toBe("root-transform");
for (const t of ["scale: 1", "rotate: 0deg", "translate: 0px"])
expect(code(mount("", t), env())).toBeNull();
});

it("reports a root mix-blend-mode other than normal", () => {
for (const major of VERSIONS)
expect(codeOn(major, mount("", "mix-blend-mode: multiply"), env())).toBe("root-blend-mode");
expect(code(mount("", "mix-blend-mode: normal"), env())).toBeNull();
});
});

describe("root-opacity", () => {
it("applies from 155 and when the version is unknown, not on 152", () => {
it("applies on every version, 152 included", () => {
const root = mount("", "opacity: 0.5");
expect(code(root, env({ win: ua(152) }))).toBeNull();
expect(code(root, env({ win: ua(155) }))).toBe("root-opacity");
expect(code(root, env({ win: ua(158) }))).toBe("root-opacity");
expect(code(root, env({ win: ua(null) }))).toBe("root-opacity");
for (const major of VERSIONS) expect(codeOn(major, root, env())).toBe("root-opacity");
});

it("ignores a fully opaque root", () => {
expect(code(mount("", "opacity: 1"), env({ win: ua(158) }))).toBeNull();
expect(code(mount("", "opacity: 1"), env())).toBeNull();
});
});

describe("no version gating", () => {
// Mutation guard: a defect that once applied only below 157 (or from 155) must still block on
// 158 (or 152). Reintroducing any from/until test on the Chrome major fails this.
it("blocks a WebGL canvas, a root transform and a root fade on every version alike", () => {
const fixtures: Array<[string, () => HTMLElement, DefectEnv]> = [
["accelerated-canvas", () => mount("<canvas></canvas>"), env({ isAccelerated: () => true })],
["root-transform", () => mount("", "transform: translateX(5px)"), env()],
["root-opacity", () => mount("", "opacity: 0.2"), env()],
];
for (const [expected, build, e] of fixtures) {
const root = build();
const seen = new Set(VERSIONS.map((major) => codeOn(major, root, e)));
expect([...seen]).toEqual([expected]);
}
});
});

Expand All @@ -126,8 +258,8 @@ describe("backface-visibility", () => {

it("is found on every version once the scan saw one", () => {
const root = mount("");
for (const major of [152, 155, 157, 158, null]) {
expect(code(root, env({ backface: true, win: ua(major) }))).toBe("backface-visibility");
for (const major of VERSIONS) {
expect(codeOn(major, root, env({ backface: true }))).toBe("backface-visibility");
}
});

Expand All @@ -138,6 +270,12 @@ describe("backface-visibility", () => {
expect(scanBackfaceDefect(root)).toBe(true);
});

it("scan: a face turned in 3D by the individual rotate or translate properties", () => {
for (const own of ["rotate: y 180deg", "rotate: 0 1 0 180deg", "translate: 0 0 10px"])
expect(scanBackfaceDefect(mount(`<div style="${face}; ${own}"></div>`))).toBe(true);
expect(scanBackfaceDefect(mount(`<div style="${face}; rotate: 90deg"></div>`))).toBe(false);
});

it("scan: a face under a perspective ancestor", () => {
const root = mount(`<div style="perspective: 800px"><div style="${face}"></div></div>`);
expect(scanBackfaceDefect(root)).toBe(true);
Expand Down Expand Up @@ -318,6 +456,46 @@ describe("backface-visibility rules the page reaches indirectly", () => {
describe("order", () => {
it("reports the first applicable defect", () => {
const root = mount("<canvas layoutsubtree></canvas>", "opacity: 0.5");
expect(code(root, env({ win: ua(158), backface: true }))).toBe("nested-capture-canvas");
expect(code(root, env({ backface: true }))).toBe("nested-capture-canvas");
});
});

describe("createDefectScan", () => {
const face = "backface-visibility: hidden";
const run = (root: HTMLElement, steps: number, each?: (i: number) => void) => {
const scan = createDefectScan(root, () => false);
for (let i = 0; i <= steps; i++) {
each?.(i);
scan.sample(i, steps, i / 10);
}
return scan.result();
};

it("is clean for a quiet timeline", () => {
expect(run(mount(""), 20)).toBeNull();
});

it("names the defect and when it was first seen", () => {
const root = mount("");
const found = run(root, 20, (i) => {
if (i === 7) root.style.opacity = "0.5";
});
expect(found?.code).toBe("root-opacity");
expect(found?.reason).toContain("first seen at t=0.7s");
});

it("sees a rotation that a later step applies to a face with no 3D context at the start", () => {
const root = mount(`<div id="f" style="${face}"></div>`);
const el = root.querySelector<HTMLElement>("#f");
const found = run(root, 20, (i) => {
if (i === 12 && el) el.style.transform = "rotateY(90deg)";
});
expect(found?.code).toBe("backface-visibility");
expect(found?.reason).toContain("first seen at t=2.0s");
});

it("leaves a flat face alone however long the timeline", () => {
const root = mount(`<div style="${face}; transform: translateX(40px)"></div>`);
expect(run(root, 40)).toBeNull();
});
});
Loading
Loading