From 75ea71126dabdfcb815cd59416a1c201346bcdd0 Mon Sep 17 00:00:00 2001 From: Pierre-Anthony Lemieux Date: Tue, 15 Sep 2026 08:30:54 -0700 Subject: [PATCH 1/3] Use screenshots to generate reference visuals --- Gruntfile.cjs | 50 +-- README.md | 17 +- eslint.config.js | 2 +- package-lock.json | 85 ----- package.json | 6 +- properties.json | 2 - src/test/js/GenerateReferenceFilesTest.js | 7 +- src/test/script/gen-reference-files.mjs | 7 +- src/test/script/gen-render-package.mjs | 21 +- src/test/script/gen-renders-page.mjs | 116 ------- src/test/script/png-diff.sh | 3 - src/test/script/refpngcompare.py | 56 ---- src/test/script/render-harness.browser.js | 223 +++++++++++++ src/test/script/render-harness.mjs | 214 +++++++++++++ src/test/webapp/css/gen-renders.css | 59 ---- src/test/webapp/gen-renders.html | 34 -- src/test/webapp/js/gen-renders.js | 364 ---------------------- 17 files changed, 458 insertions(+), 808 deletions(-) delete mode 100644 src/test/script/gen-renders-page.mjs delete mode 100644 src/test/script/png-diff.sh delete mode 100644 src/test/script/refpngcompare.py create mode 100644 src/test/script/render-harness.browser.js create mode 100644 src/test/script/render-harness.mjs delete mode 100644 src/test/webapp/css/gen-renders.css delete mode 100644 src/test/webapp/gen-renders.html delete mode 100644 src/test/webapp/js/gen-renders.js diff --git a/Gruntfile.cjs b/Gruntfile.cjs index e568c534..50e3c184 100644 --- a/Gruntfile.cjs +++ b/Gruntfile.cjs @@ -6,45 +6,7 @@ module.exports = function (grunt) { pkg: grunt.file.readJSON("package.json"), - clean: ["<%= properties.webappBuildDir %>", "<%= properties.umdBuildDir %>"], - - sync: { - all: { - files: - [ - // copy tests - { expand: true, cwd: "<%= properties.webappTestDir %>", src: "**", dest: "<%= properties.webappBuildDir %>" }, - - // copy tests resources - { expand: true, cwd: "<%= properties.unitTestsResourcesDir %>", src: "imsc-tests/imsc1/ttml/**", dest: "<%= properties.webappBuildDir %>" }, - { expand: true, cwd: "<%= properties.unitTestsResourcesDir %>", src: "imsc-tests/imsc1/tests.json", dest: "<%= properties.webappBuildDir %>" }, - { expand: true, cwd: "<%= properties.unitTestsResourcesDir %>", src: "imsc-tests/imsc1_1/ttml/**", dest: "<%= properties.webappBuildDir %>" }, - { expand: true, cwd: "<%= properties.unitTestsResourcesDir %>", src: "imsc-tests/imsc1_1/tests.json", dest: "<%= properties.webappBuildDir %>" }, - { expand: true, cwd: "<%= properties.unitTestsResourcesDir %>", src: "unit-tests/**", dest: "<%= properties.webappBuildDir %>" }, - ], - }, - - release: { - src: "<%= properties.umdBuildDir %>/<%= properties.umdMinName %>", - dest: "<%= properties.webappBuildDir %>/libs/imsc.js", - }, - - debug: { - src: "<%= properties.umdBuildDir %>/<%= properties.umdDebugName %>", - dest: "<%= properties.webappBuildDir %>/libs/imsc.js", - }, - }, - - npmcopy: { - default: { - files: { - "<%= properties.webappBuildDir %>/libs/": [ - "filesaver.js-npm:main", - "jszip/dist/jszip.js", - ], - }, - }, - }, + clean: ["<%= properties.umdBuildDir %>"], exec: { compile: { @@ -65,18 +27,10 @@ module.exports = function (grunt) { grunt.loadNpmTasks("grunt-contrib-clean"); - grunt.loadNpmTasks("grunt-npmcopy"); - - grunt.loadNpmTasks("grunt-sync"); - grunt.loadNpmTasks("grunt-exec"); grunt.registerTask("lint", ["exec:lint"]); - grunt.registerTask("build:release", ["lint", "exec:compile", "exec:bundle", "sync:all", "sync:release", "npmcopy"]); - - grunt.registerTask("build:debug", ["lint", "exec:compile", "exec:bundle", "sync:all", "sync:debug", "npmcopy"]); - - grunt.registerTask("build", ["build:debug"]); + grunt.registerTask("build", ["lint", "exec:compile", "exec:bundle"]); }; diff --git a/README.md b/README.md index c1e9d26e..4858185f 100644 --- a/README.md +++ b/README.md @@ -39,10 +39,10 @@ Rendering to HTML5 requires a browser environment, but parsing an IMSC document * run the `build` target defined in [Gruntfile.cjs](./Gruntfile.cjs) using [grunt](http://gruntjs.com/). -* the resulting `imsc.js` file at `build/public_html/libs` is the imscJS library. For example, it can be included in a web page as follows: +* the resulting `dist/imsc.debug.js` (non-minified) or `dist/imsc.min.js` (minified) file is the imscJS library. For example, it can be included in a web page as follows: ```html - + ``` See BUILD ARTIFACTS for a full list of build artifacts, and TESTS AND SAMPLES for a list of samples and tests available. @@ -80,7 +80,7 @@ imscJS consists of the following ES modules at [src/main/js](src/main/js), which ## Build -imscJS is built using the `build:release` or `build:debug` Grunt tasks -- the `build` task is an alias of `build:debug`. +imscJS is built using the `build` Grunt task. The `dist` directory contains the following build artifacts: @@ -88,11 +88,6 @@ The `dist` directory contains the following build artifacts: * `imsc.min.js`: Minified UMD build. * `main/`: ES modules and TypeScript type declarations, used when the library is imported as an NPM package. -The `build/public_html/libs/imsc.js` file is identical to: - -* `imsc.debug.js`, if the `build:debug` task is executed. -* `imsc.min.js`, if the `build:release` task is executed. - ## Releases imscJS is released as an NPM package under [imsc](https://www.npmjs.com/package/imsc). The `dev` distribution tag indicates pre-releases. @@ -105,7 +100,7 @@ To access the latest builds, please consult the [release page](https://github.co ### W3C Test Suite -The `gen-renders.html` web app or the headless script [src/test/script/gen-render-package.mjs](src/test/script/gen-render-package.mjs) can be used to generate PNG renderings as well as intermediary files (JSON document, ISD documents and HTML documents) from the [W3C IMSC test suite](https://github.com/w3c/imsc-tests). +The headless script [src/test/script/gen-render-package.mjs](src/test/script/gen-render-package.mjs) can be used to generate PNG renderings as well as intermediary files (JSON document, ISD documents and HTML documents) from the [W3C IMSC test suite](https://github.com/w3c/imsc-tests). ```sh npm run gen-imsc1 @@ -157,8 +152,4 @@ npm run gen-reference-files * [src/test](src/test): Test files -* [src/test/webapp](src/test/webapp): Web app, used to generate PNG renderings and intermediary files from the W3C IMSC test suite - * [dist](dist): Built libraries - -* [build/public_html](build/public_html): Test web applications diff --git a/eslint.config.js b/eslint.config.js index 54f53d1d..98ee1e2f 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -4,7 +4,6 @@ import globals from "globals"; export default [ { ignores: [ - "src/test/webapp/**/*", "dist", "build", ], @@ -30,6 +29,7 @@ export default [ }, { files: ["src/test/script/**/*"], + ignores: ["src/test/script/**/*.browser.js"], languageOptions: { globals: { ...globals.node, diff --git a/package-lock.json b/package-lock.json index cdc4820c..ac7bdc02 100644 --- a/package-lock.json +++ b/package-lock.json @@ -18,13 +18,10 @@ "@rollup/plugin-node-resolve": "16.0.3", "@rollup/plugin-terser": "1.0.0", "eslint": "10.10.0", - "filesaver.js-npm": "1.0.1", "globals": "17.12.0", "grunt": "1.6.3", "grunt-contrib-clean": "2.0.1", "grunt-exec": "3.0.0", - "grunt-npmcopy": "0.2.0", - "grunt-sync": "0.8.2", "jszip": "3.10.1", "rollup": "4.63.1", "rollup-plugin-polyfill-node": "0.13.0", @@ -1260,13 +1257,6 @@ "flat-cache": "^6.1.23" } }, - "node_modules/filesaver.js-npm": { - "version": "1.0.1", - "resolved": "https://registry.npmjs.org/filesaver.js-npm/-/filesaver.js-npm-1.0.1.tgz", - "integrity": "sha512-UvDxCSp2+i5oav5BEswN8nxnQiH3gzXGqtC4NDaFqFKhRLHqc0FGHceC9m8fV1WArsTnTNSJ+EyZFCHD/0zzJg==", - "dev": true, - "license": "MIT" - }, "node_modules/fill-range": { "version": "7.1.1", "resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz", @@ -1382,18 +1372,6 @@ "node": ">=0.10.0" } }, - "node_modules/fs-extra": { - "version": "6.0.1", - "resolved": "https://registry.npmjs.org/fs-extra/-/fs-extra-6.0.1.tgz", - "integrity": "sha512-GnyIkKhhzXZUWFCaJzvyDLEEgDkPfb4/TPvJCJVuS8MWZgoSsErf++QpiAlDnKFcqhRlm+tIOcencCjyJE6ZCA==", - "dev": true, - "license": "MIT", - "dependencies": { - "graceful-fs": "^4.1.2", - "jsonfile": "^4.0.0", - "universalify": "^0.1.0" - } - }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -1534,13 +1512,6 @@ "url": "https://github.com/sponsors/sindresorhus" } }, - "node_modules/graceful-fs": { - "version": "4.2.11", - "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", - "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", - "dev": true, - "license": "ISC" - }, "node_modules/grunt": { "version": "1.6.3", "resolved": "https://registry.npmjs.org/grunt/-/grunt-1.6.3.tgz", @@ -1677,35 +1648,6 @@ "node": ">=10" } }, - "node_modules/grunt-npmcopy": { - "version": "0.2.0", - "resolved": "https://registry.npmjs.org/grunt-npmcopy/-/grunt-npmcopy-0.2.0.tgz", - "integrity": "sha512-iiIpifpz5UCWPYtskdgCbDjKOoSjmwdEj9hvIHZFHVWjjfehXN6VNoeBp9c6v8XuD81BnRLRf8z/rkwmmmxvqA==", - "dev": true, - "license": "MIT", - "dependencies": { - "glob": "^7.1.3", - "lodash": "^4.17.11" - }, - "engines": { - "node": ">= 0.10.0" - } - }, - "node_modules/grunt-sync": { - "version": "0.8.2", - "resolved": "https://registry.npmjs.org/grunt-sync/-/grunt-sync-0.8.2.tgz", - "integrity": "sha512-PB+xKI9YPyZn3NZQXpKHfZVlxHdf1L8GMl+Wi0mLhYypWuOdZPW2EzTmSuhhFbXjkb0aIOxvII1zdZZEl9zqGg==", - "dev": true, - "license": "MIT", - "dependencies": { - "fs-extra": "^6.0.1", - "glob": "^7.0.5", - "md5-file": "^2.0.3" - }, - "engines": { - "node": ">=6 <7 || >=8" - } - }, "node_modules/grunt/node_modules/argparse": { "version": "1.0.10", "resolved": "https://registry.npmjs.org/argparse/-/argparse-1.0.10.tgz", @@ -2042,16 +1984,6 @@ "dev": true, "license": "MIT" }, - "node_modules/jsonfile": { - "version": "4.0.0", - "resolved": "https://registry.npmjs.org/jsonfile/-/jsonfile-4.0.0.tgz", - "integrity": "sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==", - "dev": true, - "license": "MIT", - "optionalDependencies": { - "graceful-fs": "^4.1.6" - } - }, "node_modules/jszip": { "version": "3.10.1", "resolved": "https://registry.npmjs.org/jszip/-/jszip-3.10.1.tgz", @@ -2213,13 +2145,6 @@ "node": ">=0.10.0" } }, - "node_modules/md5-file": { - "version": "2.0.7", - "resolved": "https://registry.npmjs.org/md5-file/-/md5-file-2.0.7.tgz", - "integrity": "sha512-kWAICpJv8fIY0Ka/90iOX9nCJ407Fgj82ceWwcxi2HvVkKGHRMS/Y4caqBaju5skNYXiQohGUjwGZ7rVgzUhRw==", - "dev": true, - "license": "MIT" - }, "node_modules/micromatch": { "version": "4.0.8", "resolved": "https://registry.npmjs.org/micromatch/-/micromatch-4.0.8.tgz", @@ -3000,16 +2925,6 @@ "node": "*" } }, - "node_modules/universalify": { - "version": "0.1.2", - "resolved": "https://registry.npmjs.org/universalify/-/universalify-0.1.2.tgz", - "integrity": "sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==", - "dev": true, - "license": "MIT", - "engines": { - "node": ">= 4.0.0" - } - }, "node_modules/uri-js": { "version": "4.4.1", "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", diff --git a/package.json b/package.json index 548d4a9b..b8d90d7c 100644 --- a/package.json +++ b/package.json @@ -46,8 +46,7 @@ "./package.json": "./package.json" }, "scripts": { - "prepublishOnly": "grunt build:release", - "dev": "npx http-server build/public_html", + "prepublishOnly": "grunt build", "gen-imsc1": "node src/test/script/gen-render-package.mjs imsc-tests/imsc1 build/renders-imsc1.zip", "gen-imsc1_1": "node src/test/script/gen-render-package.mjs imsc-tests/imsc1_1 build/renders-imsc1_1.zip", "gen-reference-files": "node src/test/script/gen-reference-files.mjs", @@ -67,13 +66,10 @@ "@rollup/plugin-node-resolve": "16.0.3", "@rollup/plugin-terser": "1.0.0", "eslint": "10.10.0", - "filesaver.js-npm": "1.0.1", "globals": "17.12.0", "grunt": "1.6.3", "grunt-contrib-clean": "2.0.1", "grunt-exec": "3.0.0", - "grunt-npmcopy": "0.2.0", - "grunt-sync": "0.8.2", "jszip": "3.10.1", "rollup": "4.63.1", "rollup-plugin-polyfill-node": "0.13.0", diff --git a/properties.json b/properties.json index 39e4f924..8e08b282 100644 --- a/properties.json +++ b/properties.json @@ -1,7 +1,5 @@ { "webappSrcDir" : "src/main/webapp", - "webappTestDir" : "src/test/webapp", - "webappBuildDir" : "build/public_html", "unitTestsResourcesDir" : "src/test/resources", "umdBuildDir" : "dist", "umdDebugName" : "imsc.debug.js", diff --git a/src/test/js/GenerateReferenceFilesTest.js b/src/test/js/GenerateReferenceFilesTest.js index c6a3ef4e..0cabe08e 100644 --- a/src/test/js/GenerateReferenceFilesTest.js +++ b/src/test/js/GenerateReferenceFilesTest.js @@ -3,7 +3,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { test } from "node:test"; import { fileURLToPath } from "node:url"; -import { renderTTMLInBrowser } from "../script/gen-renders-page.mjs"; +import { renderTTMLInBrowser, renderTestSuite } from "../script/render-harness.mjs"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const REFERENCE_FILES_ROOT = path.resolve(__dirname, "..", "resources", "reference-files"); @@ -25,10 +25,7 @@ async function generateAndCompare(browserProduct) { for (const reffilesRoot of REFFILES_ROOTS) { - const generatedFiles = await page.evaluate(async (root) => { - // eslint-disable-next-line no-undef -- injected by gen-renders.js in the page context - return await generateReferenceFiles(root); - }, reffilesRoot); + const generatedFiles = await renderTestSuite(page, reffilesRoot); const referenceDir = path.join(REFERENCE_FILES_ROOT, path.basename(reffilesRoot)); diff --git a/src/test/script/gen-reference-files.mjs b/src/test/script/gen-reference-files.mjs index 36cd1438..6a7367b8 100644 --- a/src/test/script/gen-reference-files.mjs +++ b/src/test/script/gen-reference-files.mjs @@ -36,7 +36,7 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { renderTTMLInBrowser } from "./gen-renders-page.mjs"; +import { renderTTMLInBrowser, renderTestSuite } from "./render-harness.mjs"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const OUTPUT_ROOT = path.resolve(__dirname, "..", "resources", "reference-files"); @@ -51,10 +51,7 @@ async function main() { for (const reffilesRoot of REFFILES_ROOTS) { console.log(`Generating reference files for "${reffilesRoot}"...`); - const files = await page.evaluate(async (root) => { - // eslint-disable-next-line no-undef -- injected by gen-renders.js in the page context - return await generateReferenceFiles(root); - }, reffilesRoot); + const files = await renderTestSuite(page, reffilesRoot); const destDir = path.join(OUTPUT_ROOT, path.basename(reffilesRoot)); diff --git a/src/test/script/gen-render-package.mjs b/src/test/script/gen-render-package.mjs index 104709d9..16a6c4d3 100644 --- a/src/test/script/gen-render-package.mjs +++ b/src/test/script/gen-render-package.mjs @@ -34,7 +34,8 @@ import fs from "node:fs"; import path from "node:path"; -import { renderTTMLInBrowser } from "./gen-renders-page.mjs"; +import JSZip from "jszip"; +import { renderTTMLInBrowser, renderTestSuite } from "./render-harness.mjs"; async function main() { const args = process.argv.slice(2).filter((a) => !a.startsWith("--")); @@ -46,19 +47,15 @@ async function main() { console.log(`Generating renders for "${reffilesRoot}"...`); - const base64Zip = await renderTTMLInBrowser(browserProduct, (page) => page.evaluate(async (root) => { - // eslint-disable-next-line no-undef -- injected by gen-renders.js in the page context - const blob = await generateRenderPackageAsZip(root); + const files = await renderTTMLInBrowser(browserProduct, (page) => renderTestSuite(page, reffilesRoot, true)); - return new Promise((resolve, reject) => { - const reader = new FileReader(); - reader.onload = () => resolve(reader.result.split(",")[1]); - reader.onerror = () => reject(reader.error); - reader.readAsDataURL(blob); - }); - }, reffilesRoot)); + const zip = new JSZip(); - fs.writeFileSync(outFile, Buffer.from(base64Zip, "base64")); + for (const [name, contents] of Object.entries(files)) { + zip.file(name, contents); + } + + fs.writeFileSync(outFile, await zip.generateAsync({ type: "nodebuffer" })); console.log(`Wrote ${outFile}`); } diff --git a/src/test/script/gen-renders-page.mjs b/src/test/script/gen-renders-page.mjs deleted file mode 100644 index 3ccccca1..00000000 --- a/src/test/script/gen-renders-page.mjs +++ /dev/null @@ -1,116 +0,0 @@ -/* - * Copyright (c) Sandflow Consulting LLC - * All rights reserved. - * - * Redistribution and use in source and binary forms, with or without - * modification, are permitted provided that the following conditions are met: - * - * * Redistributions of source code must retain the above copyright notice, this - * list of conditions and the following disclaimer. - * * Redistributions in binary form must reproduce the above copyright notice, - * this list of conditions and the following disclaimer in the documentation - * and/or other materials provided with the distribution. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" - * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE - * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE - * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE - * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR - * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF - * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS - * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN - * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) - * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE - * POSSIBILITY OF SUCH DAMAGE. - */ - -/* - * Render TTML files in browser - */ - -import fs from "node:fs"; -import path from "node:path"; -import { fileURLToPath } from "node:url"; -import puppeteer from "puppeteer"; - -const __dirname = path.dirname(fileURLToPath(import.meta.url)); -export const PUBLIC_DIR = path.resolve(__dirname, "..", "..", "..", "build", "public_html"); - -const MIME_TYPES = { - ".html": "text/html", - ".js": "text/javascript", - ".css": "text/css", - ".json": "application/json", - ".ttml": "application/xml", - ".xml": "application/xml", - ".png": "image/png", -}; - -/** - * Renders a TTML file within the `gen-renders.html` page using puppeteer - * - * @param {string} browserProduct Puppeteer browser product to launch, e.g. "chrome" or "firefox" - * @param {(page: import("puppeteer").Page) => Promise<*>} fn Callback invoked with the Puppeteer page, once gen-renders.html has loaded - * @returns {Promise<*>} Whatever fn's returned promise resolves to - */ -export async function renderTTMLInBrowser(browserProduct, fn) { - if (!fs.existsSync(PUBLIC_DIR)) { - throw new Error(`${PUBLIC_DIR} does not exist. Run "grunt build" first.`); - } - - const browser = await puppeteer.launch({ - browser: browserProduct, - headless: true, - args: browserProduct === "chrome" ? ["--no-sandbox", "--disable-setuid-sandbox"] : [], - }); - - try { - const page = await browser.newPage(); - - page.on("console", (msg) => console.log(`[page] ${msg.text()}`)); - page.on("pageerror", (err) => console.error(`[page error] ${err}`)); - - // Answers every request against a fake origin straight from - // build/public_html, so no real HTTP server needs to be listening. - await page.setRequestInterception(true); - - page.on("request", (request) => { - // data: URIs (embedded images, generated SVGs) are self-contained - // and don't need serving from disk; let the browser handle them. - if (!request.url().startsWith("http://gen-renders.local")) { - request.continue(); - return; - } - - const urlPath = decodeURIComponent(new URL(request.url()).pathname); - const filePath = path.join(PUBLIC_DIR, urlPath); - - if (!filePath.startsWith(PUBLIC_DIR)) { - console.error(`[serve] 403 ${request.url()} (outside ${PUBLIC_DIR})`); - request.respond({ status: 403 }); - return; - } - - fs.readFile(filePath, (err, data) => { - if (err) { - console.error(`[serve] 404 ${request.url()} -> ${filePath}`); - request.respond({ status: 404 }); - return; - } - - const ext = path.extname(filePath).toLowerCase(); - request.respond({ - status: 200, - contentType: MIME_TYPES[ext] || "application/octet-stream", - body: data, - }); - }); - }); - - await page.goto("http://gen-renders.local/gen-renders.html", { waitUntil: "load" }); - - return await fn(page); - } finally { - await browser.close(); - } -} diff --git a/src/test/script/png-diff.sh b/src/test/script/png-diff.sh deleted file mode 100644 index 0c78c20f..00000000 --- a/src/test/script/png-diff.sh +++ /dev/null @@ -1,3 +0,0 @@ -#!/bin/sh -#compare $2 $1 png:- | montage -geometry +4+4 $2 - $1 png:- | imdisplay -title "$1" - -compare $2 $1 png:- | montage -geometry +4+4 $2 - $1 show: \ No newline at end of file diff --git a/src/test/script/refpngcompare.py b/src/test/script/refpngcompare.py deleted file mode 100644 index d0d9aaed..00000000 --- a/src/test/script/refpngcompare.py +++ /dev/null @@ -1,56 +0,0 @@ -import argparse -import subprocess -import os -import fnmatch -import re -import sys - -parser = argparse.ArgumentParser() - -parser.add_argument("ref_dir", help = "Path of the Reference directory") - -parser.add_argument("render_dir", help = "Path of the Generated directory") - -parser.add_argument("-d", help = "Specifies whether a diff of images is output") - -parser.add_argument("-t", type=float, help = "Specifies the difference threshold", default=0.00001) - -args = parser.parse_args() - -for dir, dirs, files in os.walk(args.ref_dir): - for file in files: - reffile = os.path.join(dir, file) - destdir = os.path.join(args.render_dir, os.path.relpath(dir, args.ref_dir)) - genfile = os.path.join(destdir, file) - - if not os.path.exists(genfile): - print("File " + genfile + " does not exist") - continue - - if fnmatch.fnmatch(file, '*.png'): - try: - subprocess.check_output(["magick", "compare", '-metric', 'mse', reffile, genfile, 'null:'], stderr=subprocess.STDOUT, universal_newlines=True) - except subprocess.CalledProcessError as err: - m = re.search('([^\(]+)\(([^\)]+)', err.output) - r = float(m.group(2)) - if (r > args.t) : - print(reffile + ": " + str(r)) - if args.d is not None: - diffdir = args.d - #diffdir = os.path.join(args.d, os.path.relpath(dir, args.ref_dir)) - if not os.path.exists(diffdir): - os.makedirs(diffdir) - difffile = os.path.join(args.d, os.path.relpath(dir, args.ref_dir) + "-" + file) - p1 = subprocess.Popen(["magick", "compare", reffile, genfile, "png:-"], stdout=subprocess.PIPE) - p2 = subprocess.Popen(["magick", "montage", "-mode", "concatenate", reffile, "-", genfile, difffile], stdin=p1.stdout) - p1.stdout.close() - p2.communicate() - - -# elif not fnmatch.fnmatch(file, 'manifest.json'): -# try: -# subprocess.check_output(["diff", '-w', reffile, genfile], stderr=subprocess.STDOUT, universal_newlines=True) -# except subprocess.CalledProcessError as err: -# print(err.output) - - diff --git a/src/test/script/render-harness.browser.js b/src/test/script/render-harness.browser.js new file mode 100644 index 00000000..ef953c0e --- /dev/null +++ b/src/test/script/render-harness.browser.js @@ -0,0 +1,223 @@ +/* + * Copyright (c) Sandflow Consulting LLC + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * * Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ + +/* + * Browser-side rendering helpers for the IMSC test suite. + * + * This is a classic (non-module) script injected into a blank Puppeteer page + * by render-harness.mjs, alongside the imsc UMD bundle. Its functions + * become globals that the Node side calls through page.evaluate() -- they + * are not meant to be loaded directly by a browser. + * + * PNG capture is not done here: renderEvent() only renders into #render-div + * and reports its ISD/HTML; the Node side screenshots that element itself + * (see renderTestSuite() in render-harness.mjs), since only Puppeteer -- not + * the page -- can take a real screenshot. + */ + +/* global imsc -- provided by the imsc UMD bundle, injected alongside this script */ + +const errorHandler = { + info: function (msg) { + console.log("info: " + msg); + return false; + }, + warn: function (msg) { + console.log("warn: " + msg); + return false; + }, + error: function (msg) { + console.log("error: " + msg); + return false; + }, + fatal: function (msg) { + console.log("fatal: " + msg); + return false; + }, +}; + +async function asyncLoadFile(url) { + + const response = await fetch(url); + + if (!response.ok) { + throw { + status: response.status, + statusText: response.statusText, + }; + } + + return response.text(); +} + +function getTestListPath(reffiles_root) { + return reffiles_root + "/tests.json"; +} + +function getReferenceFilePath(reffiles_root, reffile_path) { + return reffiles_root + "/ttml/" + reffile_path; +} + +function getReferenceFileDirectory(reffiles_root, reffile_path) { + return reffiles_root + "/ttml/" + reffile_path.substring(0, Math.max(reffile_path.lastIndexOf("/"), reffile_path.lastIndexOf("\\")) + 1); +} + +function getTestName(reffile_path) { + return reffile_path.split("\\").pop().split("/").pop().split(".")[0]; +} + +function customReplace(k, v) { + if (k === "end" && v === Number.POSITIVE_INFINITY) return "Infinity"; + return v; +} + +function getRenderDiv() { + + let vdiv = document.getElementById("render-div"); + + if (!vdiv) { + vdiv = document.createElement("div"); + vdiv.id = "render-div"; + vdiv.style.position = "relative"; + vdiv.style.background = "#A9A9A9"; + document.body.appendChild(vdiv); + } + + return vdiv; +} + +// Set by openTTMLFile(), read by renderEvent(): the page keeps at most one +// parsed document "open" at a time, rendered sequentially event by event. +let currentDoc = null; +let currentReffileDir = ""; + +/** + * Fetches and parses tests.json at reffiles_root, returning the list of test + * file descriptors it contains. + */ +// eslint-disable-next-line no-unused-vars -- called from Node via page.evaluate +async function loadTestList(reffiles_root) { + const contents = await asyncLoadFile(getTestListPath(reffiles_root)); + return JSON.parse(contents); +} + +/** + * Loads and parses a single reference file, making it the current document + * that renderEvent() renders events from. Returns its name, its media time + * events, and its JSON document (already stringified, so that "Infinity" + * survives the trip back to Node -- see customReplace()). + */ +// eslint-disable-next-line no-unused-vars -- called from Node via page.evaluate +async function openTTMLFile(reffiles_root, finfo) { + + const contents = await asyncLoadFile(getReferenceFilePath(reffiles_root, finfo.path)); + + currentDoc = imsc.fromXML(contents.replace(/\r\n/g, "\n"), errorHandler); + currentReffileDir = getReferenceFileDirectory(reffiles_root, finfo.path); + + return { + "name": finfo.name || getTestName(finfo.path), + "events": currentDoc.getMediaTimeEvents(), + "docJson": JSON.stringify(currentDoc, customReplace, 2), + }; +} + +/** + * Renders the current document (see openTTMLFile()) at offset into + * #render-div, sized width x height, resolving any images it references + * first. Returns the ISD (already stringified, as with openTTMLFile()) and + * the rendered HTML; the caller is expected to screenshot #render-div itself + * to obtain a PNG. + */ +// eslint-disable-next-line no-unused-vars -- called from Node via page.evaluate +async function renderEvent(offset, params, width, height) { + + const isd = imsc.generateISD(currentDoc, offset); + + const vdiv = getRenderDiv(); + + vdiv.style.height = height + "px"; + vdiv.style.width = width + "px"; + + while (vdiv.firstChild) { + vdiv.removeChild(vdiv.firstChild); + } + + /* resolve images referenced by the ISD so they are embedded in the HTML */ + + const imgs = []; + + const imgr = function (uri, img) { + const p = (async function () { + + const url = await new Promise(function (resolve) { + + const png = new Image(); + + png.onload = function () { + const canvas = document.createElement("canvas"); + canvas.width = this.naturalWidth; + canvas.height = this.naturalHeight; + + const ctx = canvas.getContext("2d"); + + ctx.drawImage(this, 0, 0); + + resolve(canvas.toDataURL("image/png")); + }; + + png.src = currentReffileDir + uri; + + }); + + img.src = url; + + })(); + + imgs.push(p); + + return null; + }; + + imsc.renderHTML( + isd, + vdiv, + imgr, + height, + width, + params.displayForcedOnlyMode === true, + errorHandler, + ); + + await Promise.all(imgs); + + const html = vdiv.innerHTML.replace(/>\n<"); + + return { + "isdJson": JSON.stringify(isd, customReplace, 2), + "html": html, + }; +} diff --git a/src/test/script/render-harness.mjs b/src/test/script/render-harness.mjs new file mode 100644 index 00000000..93b73c55 --- /dev/null +++ b/src/test/script/render-harness.mjs @@ -0,0 +1,214 @@ +/* + * Copyright (c) Sandflow Consulting LLC + * All rights reserved. + * + * Redistribution and use in source and binary forms, with or without + * modification, are permitted provided that the following conditions are met: + * + * * Redistributions of source code must retain the above copyright notice, this + * list of conditions and the following disclaimer. + * * Redistributions in binary form must reproduce the above copyright notice, + * this list of conditions and the following disclaimer in the documentation + * and/or other materials provided with the distribution. + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" + * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE + * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE + * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE + * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR + * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF + * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS + * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN + * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) + * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE + * POSSIBILITY OF SUCH DAMAGE. + */ + +/* + * Render TTML files in browser + */ + +import fs from "node:fs"; +import path from "node:path"; +import { fileURLToPath } from "node:url"; +import puppeteer from "puppeteer"; + +const __dirname = path.dirname(fileURLToPath(import.meta.url)); + +// Test fixtures (tests.json, *.ttml, referenced images) are served straight +// from here, so nothing needs to be pre-built or pre-copied anywhere. +export const RESOURCES_DIR = path.resolve(__dirname, "..", "resources"); + +// The imsc UMD bundle, produced by `npx tsc && npx rollup -c`. +export const IMSC_BUNDLE_PATH = path.resolve(__dirname, "..", "..", "..", "dist", "imsc.debug.js"); + +const RENDERER_SCRIPT_PATH = path.resolve(__dirname, "render-harness.browser.js"); + +const FAKE_ORIGIN = "http://gen-renders.local"; + +// Fixed render dimensions used throughout the IMSC test suite. +const RENDER_WIDTH = 640; +const RENDER_HEIGHT = 360; + +const MIME_TYPES = { + ".json": "application/json", + ".ttml": "application/xml", + ".xml": "application/xml", + ".png": "image/png", +}; + +/** + * Renders a TTML file within a blank page pre-loaded with the imsc library + * and the helpers from render-harness.browser.js, using puppeteer. + * + * @param {string} browserProduct Puppeteer browser product to launch, e.g. "chrome" or "firefox" + * @param {(page: import("puppeteer").Page) => Promise<*>} fn Callback invoked with the Puppeteer page, once set up + * @returns {Promise<*>} Whatever fn's returned promise resolves to + */ +export async function renderTTMLInBrowser(browserProduct, fn) { + if (!fs.existsSync(IMSC_BUNDLE_PATH)) { + throw new Error(`${IMSC_BUNDLE_PATH} does not exist. Run "npx tsc && npx rollup -c" first.`); + } + + const browser = await puppeteer.launch({ + browser: browserProduct, + headless: true, + args: browserProduct === "chrome" ? ["--no-sandbox", "--disable-setuid-sandbox"] : [], + }); + + try { + const page = await browser.newPage(); + + page.on("console", (msg) => console.log(`[page] ${msg.text()}`)); + page.on("pageerror", (err) => console.error(`[page error] ${err}`)); + + // Answers every request against a fake origin straight from + // src/test/resources, so no real HTTP server needs to be listening. + await page.setRequestInterception(true); + + page.on("request", (request) => { + // data: URIs (embedded images, generated SVGs) are self-contained + // and don't need serving from disk; let the browser handle them. + if (!request.url().startsWith(FAKE_ORIGIN)) { + request.continue(); + return; + } + + const urlPath = decodeURIComponent(new URL(request.url()).pathname); + + if (urlPath === "/") { + request.respond({ status: 200, contentType: "text/html", body: "" }); + return; + } + + const filePath = path.join(RESOURCES_DIR, urlPath); + + if (!filePath.startsWith(RESOURCES_DIR)) { + console.error(`[serve] 403 ${request.url()} (outside ${RESOURCES_DIR})`); + request.respond({ status: 403 }); + return; + } + + fs.readFile(filePath, (err, data) => { + if (err) { + console.error(`[serve] 404 ${request.url()} -> ${filePath}`); + request.respond({ status: 404 }); + return; + } + + const ext = path.extname(filePath).toLowerCase(); + request.respond({ + status: 200, + contentType: MIME_TYPES[ext] || "application/octet-stream", + body: data, + }); + }); + }); + + await page.goto(`${FAKE_ORIGIN}/`, { waitUntil: "load" }); + + // Matches the fixed render dimensions, so element screenshots need + // no scaling or clipping surprises. + await page.setViewport({ width: RENDER_WIDTH, height: RENDER_HEIGHT }); + + await page.addScriptTag({ path: IMSC_BUNDLE_PATH }); + await page.addScriptTag({ path: RENDERER_SCRIPT_PATH }); + + return await fn(page); + } finally { + await browser.close(); + } +} + +/** + * Renders every test file at reffilesRoot (as listed in its tests.json) + * within page, returning a flat map of relative file path to file contents: + * the JSON document and ISD documents for every media time event, plus -- + * when includeRenders is true -- the HTML document and a PNG screenshot (as + * a Buffer) for each of those. Nothing is written to disk or zipped here. + * + * Events are rendered one at a time, in Node: only Puppeteer (not the page + * itself) can take a real screenshot of #render-div, so each event must be + * rendered and screenshotted before the next one reuses that same element. + * + * With includeRenders true, JSON/HTML paths are nested under "generated/" + * and PNGs under "png/", matching the layout expected in a render package. + * + * @param {import("puppeteer").Page} page A page set up by renderTTMLInBrowser() + * @param {string} reffilesRoot e.g. "imsc-tests/imsc1" + * @param {boolean} includeRenders Whether to also render/screenshot each event + * @returns {Promise>} + */ +export async function renderTestSuite(page, reffilesRoot, includeRenders = false) { + // eslint-disable-next-line no-undef -- injected by render-harness.browser.js in the page context + const finfos = await page.evaluate((root) => loadTestList(root), reffilesRoot); + + const docPrefix = includeRenders ? "generated/" : ""; + + const files = {}; + const manifest = {}; + + for (const finfo of finfos) { + const { name, events, docJson } = await page.evaluate( + // eslint-disable-next-line no-undef -- injected by render-harness.browser.js in the page context + (root, fi) => openTTMLFile(root, fi), + reffilesRoot, + finfo, + ); + + files[docPrefix + name + "/doc.json"] = docJson; + + const eventNames = []; + + for (const offset of events) { + const eventName = offset.toFixed(6).toString(); + + const { isdJson, html } = await page.evaluate( + // eslint-disable-next-line no-undef -- injected by render-harness.browser.js in the page context + (o, p, w, h) => renderEvent(o, p, w, h), + offset, + finfo.params || {}, + RENDER_WIDTH, + RENDER_HEIGHT, + ); + + files[docPrefix + name + "/isd/" + eventName + ".json"] = isdJson; + + if (includeRenders) { + files[docPrefix + name + "/html/" + eventName + ".html"] = html; + + const renderDiv = await page.$("#render-div"); + files["png/" + name + "/" + eventName + ".png"] = await renderDiv.screenshot({ type: "png" }); + await renderDiv.dispose(); + } + + eventNames.push(eventName); + } + + manifest[name] = eventNames; + } + + files[docPrefix + "file-list.json"] = JSON.stringify(manifest, null, 2); + + return files; +} diff --git a/src/test/webapp/css/gen-renders.css b/src/test/webapp/css/gen-renders.css deleted file mode 100644 index d7fb994e..00000000 --- a/src/test/webapp/css/gen-renders.css +++ /dev/null @@ -1,59 +0,0 @@ -body { - font-family: Arial, Helvetica, sans-serif; - background-color: gray; -} - -#main { - background-color: white; - border-left: black solid 1px; - border-right: black solid 1px; - - width: 1280px; - - position: absolute; - top:20px; - left: 0; - right: 0; - padding: 20px; - - margin: auto; - - text-align: center; -} - -#file-input { - width:512px; -} - -#upload { - background: lightblue; - padding: 10px; - width: 800px; - border: darkblue solid 1px; - text-align: left; - line-height: 120%; - font-size: 14px; -} - -#visual { - width: 800px; -} - - -#footer { - color: gray; - font-size: xx-small; - margin-bottom: 0; -} - -#render-div { - background: #b5bdc8; - background: -moz-linear-gradient(-45deg, #b5bdc8 0%, #828c95 36%, #28343b 100%); - background: -webkit-linear-gradient(-45deg, #b5bdc8 0%,#828c95 36%,#28343b 100%); - background: linear-gradient(135deg, #b5bdc8 0%,#828c95 36%,#28343b 100%); - filter: progid:DXImageTransform.Microsoft.gradient( startColorstr='#b5bdc8', endColorstr='#28343b',GradientType=1 ); - margin : auto; - height : 450px; - width: 800px; - overflow: hidden; -} diff --git a/src/test/webapp/gen-renders.html b/src/test/webapp/gen-renders.html deleted file mode 100644 index e0268ab6..00000000 --- a/src/test/webapp/gen-renders.html +++ /dev/null @@ -1,34 +0,0 @@ - - - - - - Reference Render Generation - - - - - - - - - - - -
- -
- - -
- -
-
-
- -
- - - diff --git a/src/test/webapp/js/gen-renders.js b/src/test/webapp/js/gen-renders.js deleted file mode 100644 index 515a2ce1..00000000 --- a/src/test/webapp/js/gen-renders.js +++ /dev/null @@ -1,364 +0,0 @@ -/* - * Copyright (c) 2016, Pierre-Anthony Lemieux - * All rights reserved. - * - * Redistribution and use in source and binary forms, with or without - * modification, are permitted provided that the following conditions are met: - * - * * Redistributions of source code must retain the above copyright notice, this - * list of conditions and the following disclaimer. - * * Redistributions in binary form must reproduce the above copyright notice, - * this list of conditions and the following disclaimer in the documentation - * and/or other materials provided with the distribution. - * - * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" - * AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE - * IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE - * ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE - * LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR - * CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF - * SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS - * INTERRUPTION) HOWEVER CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN - * CONTRACT, STRICT LIABILITY, OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) - * ARISING IN ANY WAY OUT OF THE USE OF THIS SOFTWARE, EVEN IF ADVISED OF THE - * POSSIBILITY OF SUCH DAMAGE. - */ - - -const errorHandler = { - info: function (msg) { - console.log("info: " + msg); - return false; - }, - warn: function (msg) { - console.log("warn: " + msg); - return false; - }, - error: function (msg) { - console.log("error: " + msg); - return false; - }, - fatal: function (msg) { - console.log("fatal: " + msg); - return false; - } -}; - -async function asyncLoadFile(url) { - - const response = await fetch(url); - - if (!response.ok) { - throw { - status: response.status, - statusText: response.statusText - }; - } - - return response.text(); -} - -function getTestListPath(reffiles_root) { - return reffiles_root + "/tests.json"; -} - -function getReferenceFilePath(reffiles_root, reffile_path) { - return reffiles_root + "/ttml/" + reffile_path; -} - -function getReferenceFileDirectory(reffiles_root, reffile_path) { - return reffiles_root + "/ttml/" + reffile_path.substring(0, Math.max(reffile_path.lastIndexOf("/"), reffile_path.lastIndexOf("\\")) + 1); -} - -function getTestName(reffile_path, renderer_params) { - return reffile_path.split('\\').pop().split('/').pop().split(".")[0]; -} - -function filenameFromOffset(offset) { - return offset.toFixed(6).toString(); -} - -async function generateRenderPackageAsZip(reffiles_root) { - - const zip = new JSZip(); - - const renders_dir = zip.folder('generated'); - const pngs_dir = zip.folder('png'); - - const contents = await asyncLoadFile(getTestListPath(reffiles_root)); - - const finfos = JSON.parse(contents); - - const p = []; - - for (const finfo of finfos) { - - p.push(renderTTMLFile(reffiles_root, finfo)); - - } - - const renders = await Promise.all(p); - - /* write the JSON document, ISD documents, HTML documents and PNGs */ - - const manifest = {}; - - for (const renderedFile of renders) { - - const test_renders_dir = renders_dir.folder(renderedFile.name); - const test_pngs_dir = pngs_dir.folder(renderedFile.name); - - test_renders_dir.file("doc.json", JSON.stringify(renderedFile.doc, customReplace, 2)); - - const isd_dir = test_renders_dir.folder('isd'); - const html_dir = test_renders_dir.folder('html'); - - const event_names = []; - - for (const event of renderedFile.events) { - - isd_dir.file(event.name + ".json", JSON.stringify(event.isd, customReplace, 2)); - html_dir.file(event.name + ".html", event.html); - test_pngs_dir.file(event.name + ".png", event.png, {base64: true}); - - event_names.push(event.name); - - } - - manifest[renderedFile.name] = event_names; - - } - - renders_dir.file("file-list.json", JSON.stringify(manifest, customReplace, 2)); - - return await zip.generateAsync({type: "blob"}); -} - -async function downloadRenderPackageAsZip(reffiles_root) { - - const zipfile = await generateRenderPackageAsZip(reffiles_root); - - return saveAs(zipfile, "renders.zip"); - -} - -/** - * Parses the test files at reffiles_root and generates the JSON document and - * ISD documents for each of their media time events (no HTML, no PNGs), - * returning a flat map of relative file path to file contents rather than - * writing any files or zipping anything. - */ -async function generateReferenceFiles(reffiles_root) { - - const contents = await asyncLoadFile(getTestListPath(reffiles_root)); - - const finfos = JSON.parse(contents); - - const p = []; - - for (const finfo of finfos) { - - p.push(renderTTMLFile(reffiles_root, finfo)); - - } - - const renders = await Promise.all(p); - - const files = {}; - const manifest = {}; - - for (const render of renders) { - - files[render.name + "/doc.json"] = JSON.stringify(render.doc, customReplace, 2); - - const event_names = []; - - for (const event of render.events) { - - files[render.name + "/isd/" + event.name + ".json"] = JSON.stringify(event.isd, customReplace, 2); - - event_names.push(event.name); - - } - - manifest[render.name] = event_names; - - } - - files["file-list.json"] = JSON.stringify(manifest, customReplace, 2); - - return files; -} - -/** - * Parses a reference file and generates the JSON document, ISD documents, - * HTML documents and PNGs for each of its media time events, returning them - * all as a plain JSON structure rather than writing any files. - */ -async function renderTTMLFile(reffiles_root, finfo) { - - const test_name = finfo.name || getTestName(finfo.path, finfo.params || {}); - - const contents = await asyncLoadFile(getReferenceFilePath(reffiles_root, finfo.path)); - - const doc = imsc.fromXML(contents.replace(/\r\n/g, '\n'), errorHandler); - - const events = doc.getMediaTimeEvents(); - - const p = []; - - for (const offset of events) { - - p.push(renderISD(doc, offset, finfo.params || {}, getReferenceFileDirectory(reffiles_root, finfo.path))); - - } - - const processedEvents = await Promise.all(p); - - return { - 'name': test_name, - 'doc': doc, - 'events': processedEvents - }; -} - -function customReplace(k, v) { - if (k === "end" && v === Number.POSITIVE_INFINITY) return "Infinity"; - return v; -} - -/** - * Renders a single media time event and returns its ISD, HTML and PNG as a - * plain JSON structure rather than writing any files. - */ -async function renderISD(doc, offset, params, reffile_dir) { - - const name = filenameFromOffset(offset); - - const isd = imsc.generateISD(doc, offset); - - const exp_width = 640; - const exp_height = 360; - - const vdiv = document.getElementById('render-div'); - - vdiv.style.height = exp_height + "px"; - vdiv.style.width = exp_width + "px"; - - while (vdiv.firstChild) { - vdiv.removeChild(vdiv.firstChild); - } - - /* create svg container */ - - const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg"); - svg.setAttribute('width', exp_width + "px"); - svg.setAttribute('height', exp_height + "px"); - svg.setAttribute("xmlns", svg.namespaceURI); - - const fo = document.createElementNS("http://www.w3.org/2000/svg", "foreignObject"); - fo.setAttribute('width', '100%'); - fo.setAttribute('height', '100%'); - - svg.appendChild(fo); - - /* create container div */ - - const rdiv = document.createElement("div"); - rdiv.style.height = "100%"; - rdiv.style.width = "100%"; - rdiv.style.position = "relative"; - rdiv.style.background = "#A9A9A9"; - - fo.appendChild(rdiv); - - vdiv.appendChild(svg); - - /* resolve images referenced by the ISD so they are embedded in the HTML */ - - const imgs = []; - - const imgr = function (uri, img) { - const p = (async function () { - - const url = await new Promise(function (resolve) { - - const png = new Image(); - - png.onload = function () { - const canvas = document.createElement('canvas'); - canvas.width = this.naturalWidth; - canvas.height = this.naturalHeight; - - const ctx = canvas.getContext('2d'); - - ctx.drawImage(this, 0, 0); - - // Get raw image data - - resolve(canvas.toDataURL('image/png')); - }; - - png.src = reffile_dir + uri; - - }); - - img.src = url; - - })(); - - imgs.push(p); - - return null; - }; - - imsc.renderHTML( - isd, - rdiv, - imgr, - exp_height, - exp_width, - params.displayForcedOnlyMode === true, - errorHandler - ); - - - await Promise.all(imgs); - - const html = rdiv.innerHTML.replace(/>\n<"); - - /* create PNG render */ - - const svgser = (new XMLSerializer).serializeToString(svg); - - const canvas = document.createElement("canvas"); - - const ctx = canvas.getContext('2d'); - ctx.canvas.height = exp_height; - ctx.canvas.width = exp_width; - - const url = "data:image/svg+xml;charset=utf-8," + encodeURIComponent(svgser); - - const png = await new Promise(function (resolve) { - - const img = new Image(); - img.onload = function () { - ctx.drawImage(img, 0, 0); - - const data = canvas.toDataURL("image/png"); - - resolve(data.substr(data.indexOf(',') + 1)); - }; - - img.src = url; - - }); - - return { - 'name': name, - 'isd': isd, - 'html': html, - 'png': png - }; - -} From 9126ab43ea097a7d17b9546f9bada794a2bd252b Mon Sep 17 00:00:00 2001 From: Pierre-Anthony Lemieux Date: Tue, 15 Sep 2026 12:34:53 -0700 Subject: [PATCH 2/3] Clean-up --- src/test/js/GenerateReferenceFilesTest.js | 44 +++---- src/test/script/gen-reference-files.mjs | 31 ++--- src/test/script/gen-render-package.mjs | 4 +- src/test/script/render-harness.browser.js | 13 +- src/test/script/render-harness.mjs | 138 +++++++++------------- 5 files changed, 100 insertions(+), 130 deletions(-) diff --git a/src/test/js/GenerateReferenceFilesTest.js b/src/test/js/GenerateReferenceFilesTest.js index 0cabe08e..8f5cde6a 100644 --- a/src/test/js/GenerateReferenceFilesTest.js +++ b/src/test/js/GenerateReferenceFilesTest.js @@ -3,7 +3,7 @@ import fs from "node:fs/promises"; import path from "node:path"; import { test } from "node:test"; import { fileURLToPath } from "node:url"; -import { renderTTMLInBrowser, renderTestSuite } from "../script/render-harness.mjs"; +import { renderTestSuite } from "../script/render-harness.mjs"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const REFERENCE_FILES_ROOT = path.resolve(__dirname, "..", "resources", "reference-files"); @@ -21,38 +21,38 @@ async function listFilesRecursively(dir) { async function generateAndCompare(browserProduct) { - await renderTTMLInBrowser(browserProduct, async (page) => { + const results = []; - for (const reffilesRoot of REFFILES_ROOTS) { + for (const reffilesRoot of REFFILES_ROOTS) { + results.push([reffilesRoot, await renderTestSuite(browserProduct, reffilesRoot)]); + } - const generatedFiles = await renderTestSuite(page, reffilesRoot); + for (const [reffilesRoot, generatedFiles] of results) { - const referenceDir = path.join(REFERENCE_FILES_ROOT, path.basename(reffilesRoot)); + const referenceDir = path.join(REFERENCE_FILES_ROOT, path.basename(reffilesRoot)); - const generatedPaths = Object.keys(generatedFiles).sort(); - const referencePaths = await listFilesRecursively(referenceDir); + const generatedPaths = Object.keys(generatedFiles).map((p) => p.replace(/^generated\//, "")).sort(); + const referencePaths = await listFilesRecursively(referenceDir); - assert.deepStrictEqual( - generatedPaths, - referencePaths, - `generated files for ${reffilesRoot} do not match the set of reference files at ${referenceDir}`, - ); - - for (const relativePath of generatedPaths) { + assert.deepStrictEqual( + generatedPaths, + referencePaths, + `generated files for ${reffilesRoot} do not match the set of reference files at ${referenceDir}`, + ); - const referenceContents = await fs.readFile(path.join(referenceDir, relativePath), "utf8"); + for (const relativePath of generatedPaths) { - assert.strictEqual( - generatedFiles[relativePath], - referenceContents, - `${reffilesRoot}/${relativePath} does not match its reference file`, - ); + const referenceContents = await fs.readFile(path.join(referenceDir, relativePath), "utf8"); - } + assert.strictEqual( + generatedFiles["generated/" + relativePath], + referenceContents, + `${reffilesRoot}/${relativePath} does not match its reference file`, + ); } - }); + } } diff --git a/src/test/script/gen-reference-files.mjs b/src/test/script/gen-reference-files.mjs index 6a7367b8..85e12629 100644 --- a/src/test/script/gen-reference-files.mjs +++ b/src/test/script/gen-reference-files.mjs @@ -36,7 +36,7 @@ import fs from "node:fs"; import path from "node:path"; import { fileURLToPath } from "node:url"; -import { renderTTMLInBrowser, renderTestSuite } from "./render-harness.mjs"; +import { renderTestSuite } from "./render-harness.mjs"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); const OUTPUT_ROOT = path.resolve(__dirname, "..", "resources", "reference-files"); @@ -47,25 +47,26 @@ async function main() { const browserArg = process.argv.find((a) => a.startsWith("--browser=")); const browserProduct = browserArg ? browserArg.split("=")[1] : "firefox"; - await renderTTMLInBrowser(browserProduct, async (page) => { - for (const reffilesRoot of REFFILES_ROOTS) { - console.log(`Generating reference files for "${reffilesRoot}"...`); + const results = []; - const files = await renderTestSuite(page, reffilesRoot); + for (const reffilesRoot of REFFILES_ROOTS) { + console.log(`Generating reference files for "${reffilesRoot}"...`); + results.push([reffilesRoot, await renderTestSuite(browserProduct, reffilesRoot)]); + } - const destDir = path.join(OUTPUT_ROOT, path.basename(reffilesRoot)); + for (const [reffilesRoot, files] of results) { + const destDir = path.join(OUTPUT_ROOT, path.basename(reffilesRoot)); - fs.rmSync(destDir, { recursive: true, force: true }); + fs.rmSync(destDir, { recursive: true, force: true }); - for (const [relativePath, contents] of Object.entries(files)) { - const filePath = path.join(destDir, relativePath); - fs.mkdirSync(path.dirname(filePath), { recursive: true }); - fs.writeFileSync(filePath, contents); - } - - console.log(`Wrote ${Object.keys(files).length} files to ${destDir}`); + for (const [relativePath, contents] of Object.entries(files)) { + const filePath = path.join(destDir, relativePath.replace(/^generated\//, "")); + fs.mkdirSync(path.dirname(filePath), { recursive: true }); + fs.writeFileSync(filePath, contents); } - }); + + console.log(`Wrote ${Object.keys(files).length} files to ${destDir}`); + } } main().catch((err) => { diff --git a/src/test/script/gen-render-package.mjs b/src/test/script/gen-render-package.mjs index 16a6c4d3..ebe5344e 100644 --- a/src/test/script/gen-render-package.mjs +++ b/src/test/script/gen-render-package.mjs @@ -35,7 +35,7 @@ import fs from "node:fs"; import path from "node:path"; import JSZip from "jszip"; -import { renderTTMLInBrowser, renderTestSuite } from "./render-harness.mjs"; +import { renderTestSuite } from "./render-harness.mjs"; async function main() { const args = process.argv.slice(2).filter((a) => !a.startsWith("--")); @@ -47,7 +47,7 @@ async function main() { console.log(`Generating renders for "${reffilesRoot}"...`); - const files = await renderTTMLInBrowser(browserProduct, (page) => renderTestSuite(page, reffilesRoot, true)); + const files = await renderTestSuite(browserProduct, reffilesRoot, true); const zip = new JSZip(); diff --git a/src/test/script/render-harness.browser.js b/src/test/script/render-harness.browser.js index ef953c0e..b3af67a2 100644 --- a/src/test/script/render-harness.browser.js +++ b/src/test/script/render-harness.browser.js @@ -26,19 +26,10 @@ /* * Browser-side rendering helpers for the IMSC test suite. - * - * This is a classic (non-module) script injected into a blank Puppeteer page - * by render-harness.mjs, alongside the imsc UMD bundle. Its functions - * become globals that the Node side calls through page.evaluate() -- they - * are not meant to be loaded directly by a browser. - * - * PNG capture is not done here: renderEvent() only renders into #render-div - * and reports its ISD/HTML; the Node side screenshots that element itself - * (see renderTestSuite() in render-harness.mjs), since only Puppeteer -- not - * the page -- can take a real screenshot. + * the global `imsc` is injected alongside this script. */ -/* global imsc -- provided by the imsc UMD bundle, injected alongside this script */ +/* global imsc */ const errorHandler = { info: function (msg) { diff --git a/src/test/script/render-harness.mjs b/src/test/script/render-harness.mjs index 93b73c55..ca0a7ce6 100644 --- a/src/test/script/render-harness.mjs +++ b/src/test/script/render-harness.mjs @@ -35,18 +35,14 @@ import puppeteer from "puppeteer"; const __dirname = path.dirname(fileURLToPath(import.meta.url)); -// Test fixtures (tests.json, *.ttml, referenced images) are served straight -// from here, so nothing needs to be pre-built or pre-copied anywhere. export const RESOURCES_DIR = path.resolve(__dirname, "..", "resources"); - -// The imsc UMD bundle, produced by `npx tsc && npx rollup -c`. export const IMSC_BUNDLE_PATH = path.resolve(__dirname, "..", "..", "..", "dist", "imsc.debug.js"); +const HARNESS_PATH = path.resolve(__dirname, "render-harness.browser.js"); -const RENDERER_SCRIPT_PATH = path.resolve(__dirname, "render-harness.browser.js"); - +/* We are not loading from an HTTP server, but instead intecerpting all load calls */ const FAKE_ORIGIN = "http://gen-renders.local"; -// Fixed render dimensions used throughout the IMSC test suite. +/* Dimensions of the rendered visuals */ const RENDER_WIDTH = 640; const RENDER_HEIGHT = 360; @@ -58,16 +54,20 @@ const MIME_TYPES = { }; /** - * Renders a TTML file within a blank page pre-loaded with the imsc library - * and the helpers from render-harness.browser.js, using puppeteer. + * Renders an IMSC test suite, returning a list containing file names and + * corresponding file contents. JSON/HTML paths are nested under "generated/" + * and, when includeRenders is true, PNGs under "png/" -- matching the layout + * expected in a render package. * - * @param {string} browserProduct Puppeteer browser product to launch, e.g. "chrome" or "firefox" - * @param {(page: import("puppeteer").Page) => Promise<*>} fn Callback invoked with the Puppeteer page, once set up - * @returns {Promise<*>} Whatever fn's returned promise resolves to + * @param {string} browserProduct Puppeteer browser product to launch, e.g. + * "chrome" or "firefox" + * @param {string} reffilesRoot e.g. "imsc-tests/imsc1" + * @param {boolean} includeRenders Whether to also render/screenshot each event + * @returns {Promise>} */ -export async function renderTTMLInBrowser(browserProduct, fn) { +export async function renderTestSuite(browserProduct, reffilesRoot, includeRenders = false) { if (!fs.existsSync(IMSC_BUNDLE_PATH)) { - throw new Error(`${IMSC_BUNDLE_PATH} does not exist. Run "npx tsc && npx rollup -c" first.`); + throw new Error(`${IMSC_BUNDLE_PATH} does not exist.`); } const browser = await puppeteer.launch({ @@ -82,13 +82,9 @@ export async function renderTTMLInBrowser(browserProduct, fn) { page.on("console", (msg) => console.log(`[page] ${msg.text()}`)); page.on("pageerror", (err) => console.error(`[page error] ${err}`)); - // Answers every request against a fake origin straight from - // src/test/resources, so no real HTTP server needs to be listening. await page.setRequestInterception(true); page.on("request", (request) => { - // data: URIs (embedded images, generated SVGs) are self-contained - // and don't need serving from disk; let the browser handle them. if (!request.url().startsWith(FAKE_ORIGIN)) { request.continue(); return; @@ -101,6 +97,11 @@ export async function renderTTMLInBrowser(browserProduct, fn) { return; } + if (urlPath === "/favicon.ico") { + request.respond({ status: 204 }); + return; + } + const filePath = path.join(RESOURCES_DIR, urlPath); if (!filePath.startsWith(RESOURCES_DIR)) { @@ -132,83 +133,60 @@ export async function renderTTMLInBrowser(browserProduct, fn) { await page.setViewport({ width: RENDER_WIDTH, height: RENDER_HEIGHT }); await page.addScriptTag({ path: IMSC_BUNDLE_PATH }); - await page.addScriptTag({ path: RENDERER_SCRIPT_PATH }); + await page.addScriptTag({ path: HARNESS_PATH }); - return await fn(page); - } finally { - await browser.close(); - } -} + // eslint-disable-next-line no-undef -- injected by render-harness.browser.js in the page context + const finfos = await page.evaluate((root) => loadTestList(root), reffilesRoot); -/** - * Renders every test file at reffilesRoot (as listed in its tests.json) - * within page, returning a flat map of relative file path to file contents: - * the JSON document and ISD documents for every media time event, plus -- - * when includeRenders is true -- the HTML document and a PNG screenshot (as - * a Buffer) for each of those. Nothing is written to disk or zipped here. - * - * Events are rendered one at a time, in Node: only Puppeteer (not the page - * itself) can take a real screenshot of #render-div, so each event must be - * rendered and screenshotted before the next one reuses that same element. - * - * With includeRenders true, JSON/HTML paths are nested under "generated/" - * and PNGs under "png/", matching the layout expected in a render package. - * - * @param {import("puppeteer").Page} page A page set up by renderTTMLInBrowser() - * @param {string} reffilesRoot e.g. "imsc-tests/imsc1" - * @param {boolean} includeRenders Whether to also render/screenshot each event - * @returns {Promise>} - */ -export async function renderTestSuite(page, reffilesRoot, includeRenders = false) { - // eslint-disable-next-line no-undef -- injected by render-harness.browser.js in the page context - const finfos = await page.evaluate((root) => loadTestList(root), reffilesRoot); + const docPrefix = "generated/"; - const docPrefix = includeRenders ? "generated/" : ""; + const files = {}; + const manifest = {}; - const files = {}; - const manifest = {}; + for (const finfo of finfos) { + const { name, events, docJson } = await page.evaluate( + // eslint-disable-next-line no-undef -- injected by render-harness.browser.js in the page context + (root, fi) => openTTMLFile(root, fi), + reffilesRoot, + finfo, + ); - for (const finfo of finfos) { - const { name, events, docJson } = await page.evaluate( - // eslint-disable-next-line no-undef -- injected by render-harness.browser.js in the page context - (root, fi) => openTTMLFile(root, fi), - reffilesRoot, - finfo, - ); + files[docPrefix + name + "/doc.json"] = docJson; - files[docPrefix + name + "/doc.json"] = docJson; + const eventNames = []; - const eventNames = []; + for (const offset of events) { + const eventName = offset.toFixed(6).toString(); - for (const offset of events) { - const eventName = offset.toFixed(6).toString(); + const { isdJson, html } = await page.evaluate( + // eslint-disable-next-line no-undef -- injected by render-harness.browser.js in the page context + (o, p, w, h) => renderEvent(o, p, w, h), + offset, + finfo.params || {}, + RENDER_WIDTH, + RENDER_HEIGHT, + ); - const { isdJson, html } = await page.evaluate( - // eslint-disable-next-line no-undef -- injected by render-harness.browser.js in the page context - (o, p, w, h) => renderEvent(o, p, w, h), - offset, - finfo.params || {}, - RENDER_WIDTH, - RENDER_HEIGHT, - ); + files[docPrefix + name + "/isd/" + eventName + ".json"] = isdJson; - files[docPrefix + name + "/isd/" + eventName + ".json"] = isdJson; + if (includeRenders) { + files[docPrefix + name + "/html/" + eventName + ".html"] = html; - if (includeRenders) { - files[docPrefix + name + "/html/" + eventName + ".html"] = html; + const renderDiv = await page.$("#render-div"); + files["png/" + name + "/" + eventName + ".png"] = await renderDiv.screenshot({ type: "png" }); + await renderDiv.dispose(); + } - const renderDiv = await page.$("#render-div"); - files["png/" + name + "/" + eventName + ".png"] = await renderDiv.screenshot({ type: "png" }); - await renderDiv.dispose(); + eventNames.push(eventName); } - eventNames.push(eventName); + manifest[name] = eventNames; } - manifest[name] = eventNames; - } - - files[docPrefix + "file-list.json"] = JSON.stringify(manifest, null, 2); + files[docPrefix + "file-list.json"] = JSON.stringify(manifest, null, 2); - return files; + return files; + } finally { + await browser.close(); + } } From d539a0985c61c9fbf4116439223a7ddf66ac1bb7 Mon Sep 17 00:00:00 2001 From: Pierre-Anthony Lemieux Date: Tue, 15 Sep 2026 12:54:08 -0700 Subject: [PATCH 3/3] Clean-up --- src/test/script/gen-reference-files.mjs | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/src/test/script/gen-reference-files.mjs b/src/test/script/gen-reference-files.mjs index 85e12629..1a154adf 100644 --- a/src/test/script/gen-reference-files.mjs +++ b/src/test/script/gen-reference-files.mjs @@ -29,8 +29,10 @@ * Generates reference files for the IMSC 1 and IMSC 1.1 test suites, writing * them to src/test/resources/reference-files//. * + * Always uses Firefox, so the committed reference files come from one + * canonical, reproducible renderer. + * * Usage: node src/test/script/gen-reference-files.mjs - * [--browser=chrome|firefox] */ import fs from "node:fs"; @@ -44,8 +46,7 @@ const OUTPUT_ROOT = path.resolve(__dirname, "..", "resources", "reference-files" const REFFILES_ROOTS = ["imsc-tests/imsc1", "imsc-tests/imsc1_1"]; async function main() { - const browserArg = process.argv.find((a) => a.startsWith("--browser=")); - const browserProduct = browserArg ? browserArg.split("=")[1] : "firefox"; + const browserProduct = "firefox"; const results = [];