338 lines
9.8 KiB
JavaScript
338 lines
9.8 KiB
JavaScript
const assert = require("node:assert/strict");
|
|
const fs = require("node:fs");
|
|
const path = require("node:path");
|
|
const vm = require("node:vm");
|
|
|
|
const mainPath = process.argv[2]
|
|
? path.resolve(process.argv[2])
|
|
: path.join(__dirname, "midctrl-homepage-optimized", "assets", "js", "main.js");
|
|
const source = fs.readFileSync(mainPath, "utf8");
|
|
|
|
const events = [];
|
|
const consoleErrors = [];
|
|
const windowListeners = new Map();
|
|
const documentListeners = new Map();
|
|
const canvasListeners = new Map();
|
|
const timers = new Map();
|
|
let nextTimerId = 1;
|
|
let rafQueue = [];
|
|
let rafTime = 0;
|
|
let nextObjectId = 1;
|
|
let reducedMotionChangeListener = null;
|
|
let documentHidden = true;
|
|
let documentReadyState = "loading";
|
|
|
|
function addListener(store, type, callback) {
|
|
if (!store.has(type)) store.set(type, []);
|
|
store.get(type).push(callback);
|
|
}
|
|
|
|
function emit(store, type, event = {}) {
|
|
for (const callback of store.get(type) || []) callback(event);
|
|
}
|
|
|
|
function flushTimers() {
|
|
const pending = [...timers.entries()];
|
|
timers.clear();
|
|
for (const [, callback] of pending) callback();
|
|
}
|
|
|
|
function flushAnimationFrames() {
|
|
const pending = rafQueue;
|
|
rafQueue = [];
|
|
rafTime += 16.667;
|
|
for (const callback of pending) callback(rafTime);
|
|
}
|
|
|
|
function makeObject(type) {
|
|
return { type, id: nextObjectId++ };
|
|
}
|
|
|
|
const constants = {
|
|
NO_ERROR: 0,
|
|
FRAMEBUFFER_COMPLETE: 0x8cd5,
|
|
MAX_TEXTURE_SIZE: 0x0d33,
|
|
MAX_RENDERBUFFER_SIZE: 0x84e8,
|
|
ALIASED_POINT_SIZE_RANGE: 0x846d,
|
|
COMPILE_STATUS: 0x8b81,
|
|
LINK_STATUS: 0x8b82,
|
|
VERTEX_SHADER: 0x8b31,
|
|
FRAGMENT_SHADER: 0x8b30,
|
|
HIGH_FLOAT: 0x8df2,
|
|
TEXTURE0: 0x84c0,
|
|
TEXTURE1: 0x84c1,
|
|
};
|
|
|
|
const glTarget = {
|
|
...constants,
|
|
failTexturePixelsAbove: Infinity,
|
|
createFramebuffer() {
|
|
events.push("createFramebuffer");
|
|
return makeObject("framebuffer");
|
|
},
|
|
deleteFramebuffer() {
|
|
events.push("deleteFramebuffer");
|
|
},
|
|
createTexture() {
|
|
return makeObject("texture");
|
|
},
|
|
deleteTexture() {},
|
|
createRenderbuffer() {
|
|
return makeObject("renderbuffer");
|
|
},
|
|
deleteRenderbuffer() {},
|
|
createShader() {
|
|
return makeObject("shader");
|
|
},
|
|
createProgram() {
|
|
return makeObject("program");
|
|
},
|
|
createBuffer() {
|
|
return makeObject("buffer");
|
|
},
|
|
texImage2D(target, level, internalFormat, width, height) {
|
|
if (width * height > this.failTexturePixelsAbove) {
|
|
events.push("textureAllocationFailed");
|
|
throw new Error("Synthetic texture allocation failure.");
|
|
}
|
|
},
|
|
getParameter(parameter) {
|
|
if (parameter === this.MAX_TEXTURE_SIZE || parameter === this.MAX_RENDERBUFFER_SIZE) return 16384;
|
|
if (parameter === this.ALIASED_POINT_SIZE_RANGE) return [1, 64];
|
|
return null;
|
|
},
|
|
getShaderPrecisionFormat() {
|
|
return { precision: 23 };
|
|
},
|
|
getShaderParameter() {
|
|
return true;
|
|
},
|
|
getProgramParameter() {
|
|
return true;
|
|
},
|
|
getShaderInfoLog() {
|
|
return "";
|
|
},
|
|
getProgramInfoLog() {
|
|
return "";
|
|
},
|
|
getUniformLocation() {
|
|
return makeObject("uniform");
|
|
},
|
|
getAttribLocation() {
|
|
return 0;
|
|
},
|
|
checkFramebufferStatus() {
|
|
return this.FRAMEBUFFER_COMPLETE;
|
|
},
|
|
getError() {
|
|
return this.NO_ERROR;
|
|
},
|
|
isContextLost() {
|
|
return false;
|
|
},
|
|
finish() {
|
|
events.push("finish");
|
|
},
|
|
};
|
|
|
|
let generatedConstant = 0x10000;
|
|
const gl = new Proxy(glTarget, {
|
|
get(target, property) {
|
|
if (property in target) return target[property];
|
|
if (typeof property === "string" && /^[A-Z0-9_]+$/.test(property)) {
|
|
target[property] = generatedConstant++;
|
|
return target[property];
|
|
}
|
|
if (typeof property === "string") {
|
|
target[property] = function noop() {};
|
|
return target[property];
|
|
}
|
|
return undefined;
|
|
},
|
|
});
|
|
|
|
const canvas = {
|
|
width: 300,
|
|
height: 150,
|
|
hidden: false,
|
|
getContext() {
|
|
return gl;
|
|
},
|
|
addEventListener(type, callback) {
|
|
addListener(canvasListeners, type, callback);
|
|
},
|
|
};
|
|
gl.canvas = canvas;
|
|
|
|
const year = { textContent: "" };
|
|
const mediaQuery = {
|
|
matches: false,
|
|
addEventListener(type, callback) {
|
|
if (type === "change") reducedMotionChangeListener = callback;
|
|
},
|
|
addListener(callback) {
|
|
reducedMotionChangeListener = callback;
|
|
},
|
|
};
|
|
const context = {
|
|
console: {
|
|
log() {},
|
|
warn() {},
|
|
error(...args) {
|
|
consoleErrors.push(args.map(String).join(" "));
|
|
},
|
|
},
|
|
document: {
|
|
get hidden() {
|
|
return documentHidden;
|
|
},
|
|
get visibilityState() {
|
|
return documentHidden ? "hidden" : "visible";
|
|
},
|
|
get readyState() {
|
|
return documentReadyState;
|
|
},
|
|
documentElement: { clientWidth: 1280, clientHeight: 720 },
|
|
querySelector(selector) {
|
|
if (selector === "#year") return year;
|
|
return null;
|
|
},
|
|
getElementById(id) {
|
|
return id === "sakura" ? canvas : null;
|
|
},
|
|
addEventListener(type, callback) {
|
|
addListener(documentListeners, type, callback);
|
|
},
|
|
},
|
|
navigator: { deviceMemory: 8 },
|
|
innerWidth: 1280,
|
|
innerHeight: 720,
|
|
devicePixelRatio: 1,
|
|
performance: { now: () => rafTime },
|
|
matchMedia() {
|
|
return mediaQuery;
|
|
},
|
|
addEventListener(type, callback) {
|
|
addListener(windowListeners, type, callback);
|
|
},
|
|
setTimeout(callback) {
|
|
const id = nextTimerId++;
|
|
timers.set(id, callback);
|
|
return id;
|
|
},
|
|
clearTimeout(id) {
|
|
timers.delete(id);
|
|
},
|
|
requestAnimationFrame(callback) {
|
|
rafQueue.push(callback);
|
|
return rafQueue.length;
|
|
},
|
|
cancelAnimationFrame() {
|
|
// Deliberately leave the callback queued to verify generation guards in
|
|
// browsers where cancellation races with a frame already being dispatched.
|
|
events.push("cancelAnimationFrame");
|
|
},
|
|
Date,
|
|
Math,
|
|
Number,
|
|
Object,
|
|
Array,
|
|
Float32Array,
|
|
Uint8Array,
|
|
isFinite,
|
|
};
|
|
context.window = context;
|
|
context.globalThis = context;
|
|
|
|
vm.runInNewContext(source, context, { filename: "main.js" });
|
|
|
|
assert.equal(events.filter((event) => event === "createFramebuffer").length, 0, "WebGL must wait only for the DOM");
|
|
documentHidden = false;
|
|
documentReadyState = "interactive";
|
|
emit(documentListeners, "DOMContentLoaded");
|
|
|
|
assert.equal(consoleErrors.length, 0, "initialization must not log errors");
|
|
assert.deepEqual([canvas.width, canvas.height], [1280, 720]);
|
|
assert.equal(events.filter((event) => event === "createFramebuffer").length, 3);
|
|
assert.equal(rafQueue.length, 1, "a missed pre-DOM visibility transition must still start animation");
|
|
|
|
function resize(width, height, dpr, burstCount = 1) {
|
|
context.innerWidth = width;
|
|
context.innerHeight = height;
|
|
context.devicePixelRatio = dpr;
|
|
context.document.documentElement.clientWidth = width;
|
|
context.document.documentElement.clientHeight = height;
|
|
for (let i = 0; i < burstCount; i++) emit(windowListeners, "resize");
|
|
assert.equal(timers.size, 1, "resize bursts must collapse to one timer");
|
|
flushTimers();
|
|
flushAnimationFrames();
|
|
}
|
|
|
|
events.length = 0;
|
|
resize(1366, 768, 1, 25);
|
|
assert.equal(events.filter((event) => event === "createFramebuffer").length, 3);
|
|
assert.equal(events.filter((event) => event === "deleteFramebuffer").length, 3);
|
|
assert.ok(events.indexOf("createFramebuffer") < events.indexOf("deleteFramebuffer"));
|
|
|
|
events.length = 0;
|
|
resize(3000, 1800, 2);
|
|
assert.ok(canvas.width * canvas.height <= 10000000, "normal pixel budget must be enforced");
|
|
|
|
events.length = 0;
|
|
resize(3200, 1800, 2);
|
|
assert.equal(events.filter((event) => event === "finish").length, 1);
|
|
assert.equal(events.filter((event) => event === "deleteFramebuffer").length, 3);
|
|
assert.equal(events.filter((event) => event === "createFramebuffer").length, 3);
|
|
assert.ok(events.indexOf("deleteFramebuffer") < events.indexOf("createFramebuffer"));
|
|
|
|
events.length = 0;
|
|
gl.failTexturePixelsAbove = 5000000;
|
|
resize(3600, 2000, 2);
|
|
assert.ok(events.filter((event) => event === "textureAllocationFailed").length >= 2);
|
|
assert.ok(canvas.width * canvas.height <= 5000000, "failed allocations must fall back to a smaller target");
|
|
assert.equal(consoleErrors.length, 0, "successful fallback must not log a terminal error");
|
|
|
|
events.length = 0;
|
|
gl.failTexturePixelsAbove = Infinity;
|
|
let contextLossPrevented = false;
|
|
|
|
documentHidden = true;
|
|
emit(windowListeners, "pagehide", { persisted: true });
|
|
assert.equal(events.filter((event) => event === "cancelAnimationFrame").length, 1);
|
|
assert.equal(rafQueue.length, 1, "the mock retains the stale frame after pagehide");
|
|
documentHidden = false;
|
|
emit(windowListeners, "pageshow", { persisted: true });
|
|
assert.equal(rafQueue.length, 2, "pageshow must queue a replacement frame");
|
|
emit(windowListeners, "pageshow", { persisted: true });
|
|
assert.equal(rafQueue.length, 3, "a repeated pageshow replaces the pending frame in the mock queue");
|
|
flushAnimationFrames();
|
|
assert.equal(rafQueue.length, 1, "only the newest pageshow loop may continue");
|
|
|
|
events.length = 0;
|
|
mediaQuery.matches = true;
|
|
reducedMotionChangeListener({ matches: true });
|
|
assert.equal(events.filter((event) => event === "cancelAnimationFrame").length, 1);
|
|
flushAnimationFrames();
|
|
assert.equal(rafQueue.length, 0, "reduced motion must leave no live animation callback");
|
|
mediaQuery.matches = false;
|
|
reducedMotionChangeListener({ matches: false });
|
|
assert.equal(rafQueue.length, 1, "animation must have one pending frame before context loss");
|
|
|
|
events.length = 0;
|
|
emit(canvasListeners, "webglcontextlost", {
|
|
preventDefault() {
|
|
contextLossPrevented = true;
|
|
},
|
|
});
|
|
emit(canvasListeners, "webglcontextrestored");
|
|
assert.equal(contextLossPrevented, true);
|
|
assert.equal(events.filter((event) => event === "createFramebuffer").length, 3);
|
|
assert.equal(events.filter((event) => event === "cancelAnimationFrame").length, 1);
|
|
assert.equal(rafQueue.length, 2, "the mock keeps both the stale and restored callbacks queued");
|
|
flushAnimationFrames();
|
|
assert.equal(rafQueue.length, 1, "only the restored animation loop may schedule the next frame");
|
|
assert.equal(consoleErrors.length, 0, "context restoration must not log errors");
|
|
|
|
console.log("PASS: cold start, page recovery, Render Target resize, fallback, and context restoration");
|