diff --git a/package-lock.json b/package-lock.json index b0beec4..6181dc9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -23,6 +23,7 @@ "eslint": "^10.12.0", "jest": "^29.1.2", "jest-it-up": "^4.0.1", + "playwright": "^1.64.1", "ts-jest": "^29.2.5", "typedoc": "^0.26.6", "typescript": "^5.5.4", @@ -5317,6 +5318,35 @@ "node": ">=8" } }, + "node_modules/playwright": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.1.tgz", + "integrity": "sha512-HCrVhHr5QQuwYoswEUGTb7gGp522yflbv/ST8nF4sFWGuLjQCt17Q/EubpGGSNKYn1tsTCPZxqHhHCfVURu7UA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "playwright-core": "1.64.1" + }, + "bin": { + "playwright": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/playwright-core": { + "version": "1.64.1", + "resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.1.tgz", + "integrity": "sha512-BC4s8y6SEVA6Q1rSxZTto8hI1KUI3xXlHGZgvGqP4IqPdI/a6FX9H29IDNz7vKo9l21b6LfYosScPL6M3xxyDQ==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "playwright-core": "cli.js" + }, + "engines": { + "node": ">=20" + } + }, "node_modules/prelude-ls": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", diff --git a/package.json b/package.json index 72abbe5..53213e7 100644 --- a/package.json +++ b/package.json @@ -14,7 +14,7 @@ "lint:fix": "eslint . --fix", "test": "npm run test:unit && npm run test:web", "test:unit": "npm run lint && jest --coverage --runInBand", - "test:web": "npm run build:code && webpack && rm -rf dist", + "test:web": "npm run build:code && webpack && npx playwright install chromium && node scripts/test-web-browser.js && rm -rf dist", "coverage:bump": "jest-it-up" }, "author": "BelfordZ", @@ -31,8 +31,8 @@ "!build/**/*.test.*" ], "devDependencies": { - "@json-schema-tools/meta-schema": "^1.7.5", "@eslint/js": "^10.0.1", + "@json-schema-tools/meta-schema": "^1.7.5", "@types/isomorphic-fetch": "^0.0.39", "@types/jest": "^29.1.1", "@types/node": "^22.5.0", @@ -40,12 +40,13 @@ "@typescript-eslint/parser": "^8.2.0", "eslint": "^10.12.0", "jest": "^29.1.2", + "jest-it-up": "^4.0.1", + "playwright": "^1.64.1", "ts-jest": "^29.2.5", "typedoc": "^0.26.6", "typescript": "^5.5.4", "webpack": "^5.94.0", - "webpack-cli": "^5.1.4", - "jest-it-up": "^4.0.1" + "webpack-cli": "^5.1.4" }, "dependencies": { "@json-schema-spec/json-pointer": "^0.1.2", diff --git a/scripts/test-web-browser.js b/scripts/test-web-browser.js new file mode 100644 index 0000000..d68ab83 --- /dev/null +++ b/scripts/test-web-browser.js @@ -0,0 +1,151 @@ +/** + * In-browser tests for the web bundle (issue #21). + * + * `npm run test:web` builds the web entry (src/index-web.ts) with webpack + * into dist/bundle.js, then this script serves that bundle plus a test page + * from a local HTTP server, loads the page in a real headless Chromium + * (via Playwright) and exercises the resolver there. + * + * This is intentionally separate from the jest unit suite, which runs in + * Node: the web entry stubs the `file` protocol handler and relies on the + * browser's fetch, so only a real browser verifies it. The page and the + * JSON fixtures are served same-origin so Chromium's Private Network + * Access rules don't interfere. + * + * Run via: npm run test:web + */ +const fs = require("fs"); +const http = require("http"); +const path = require("path"); +const { chromium } = require("playwright"); + +const BUNDLE = path.join(__dirname, "..", "dist", "bundle.js"); + +const DOC = { + title: "browser-doc", + type: "object", + definitions: { + foo: { type: "string" }, + }, +}; + +const PAGE = ` +reference-resolver web test +`; + +async function main() { + if (!fs.existsSync(BUNDLE)) { + console.error(`missing ${BUNDLE} — run the webpack build first (npm run test:web does this)`); + process.exit(2); + } + const bundle = fs.readFileSync(BUNDLE, "utf8"); + + const server = http.createServer((req, res) => { + if (req.url === "/bundle.js") { + res.writeHead(200, { "Content-Type": "text/javascript" }); + res.end(bundle); + return; + } + if (req.url === "/doc.json") { + res.writeHead(200, { "Content-Type": "application/json" }); + res.end(JSON.stringify(DOC)); + return; + } + res.writeHead(200, { "Content-Type": "text/html" }); + res.end(PAGE); + }); + await new Promise((resolve) => server.listen(0, "127.0.0.1", resolve)); + const baseUrl = `http://127.0.0.1:${server.address().port}`; + + // Bypass the ambient egress proxy: these tests only talk to the local + // server, and proxying localhost is flaky and unnecessary. + const browser = await chromium.launch({ args: ["--no-proxy-server"] }); + const page = await browser.newPage(); + await page.goto(`${baseUrl}/`, { waitUntil: "load" }); + + const hasGlobal = await page.evaluate(() => typeof window.ReferenceResolver !== "undefined"); + if (!hasGlobal) { + console.error("window.ReferenceResolver was not set by the bundle — check webpack library output"); + process.exit(2); + } + + const results = await page.evaluate(async (url) => { + const R = window.ReferenceResolver; + const out = []; + + function eq(actual, expected) { + if (JSON.stringify(actual) !== JSON.stringify(expected)) { + throw new Error(`expected ${JSON.stringify(expected)}, got ${JSON.stringify(actual)}`); + } + } + + async function throwsName(fn, name) { + try { + await fn(); + } catch (e) { + if (e && e.name === name) { + return; + } + throw new Error(`expected error ${name}, got ${e && e.name}: ${e && e.message}`); + } + throw new Error(`expected error ${name}, but nothing was thrown`); + } + + async function t(name, fn) { + try { + await fn(); + out.push({ name, ok: true }); + } catch (e) { + out.push({ name, ok: false, error: String((e && e.message) || e) }); + } + } + + await t("resolves internal refs", async () => { + eq(await R.resolve("#/properties/foo", { properties: { foo: "boo" } }), "boo"); + }); + + await t("resolves http urls", async () => { + eq((await R.resolve(`${url}/doc.json`)).title, "browser-doc"); + }); + + await t("applies hash fragment to http urls", async () => { + eq(await R.resolve(`${url}/doc.json#/definitions/foo`), { type: "string" }); + }); + + await t("rejects file refs in the browser", async () => { + await throwsName(() => R.resolve("./src/test-obj.json"), "InvalidFileSystemPathError"); + }); + + await t("errors on bad json pointer", async () => { + await throwsName(() => R.resolve("#/nope", { foo: { bar: true } }), "InvalidJsonPointerRefError"); + }); + + await t("supports custom protocol handlers", async () => { + R.protocolHandlerMap.ipfs = () => Promise.resolve({ title: "foo", type: "string" }); + eq(await R.resolve("ipfs://80088008800880088008"), { title: "foo", type: "string" }); + }); + + return out; + }, baseUrl); + + await browser.close(); + server.close(); + + let failed = 0; + for (const r of results) { + console.log(`${r.ok ? "✓" : "✗"} ${r.name}${r.ok ? "" : ` — ${r.error}`}`); + if (!r.ok) { + failed++; + } + } + if (failed > 0) { + console.error(`${failed} browser test(s) failed`); + process.exit(1); + } + console.log("all browser tests passed"); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/webpack.config.js b/webpack.config.js index d957f18..86cac58 100644 --- a/webpack.config.js +++ b/webpack.config.js @@ -4,6 +4,11 @@ module.exports = { entry: './build/index-web.js', output: { path: path.resolve(__dirname, 'dist'), - filename: 'bundle.js' + filename: 'bundle.js', + library: { + name: 'ReferenceResolver', + type: 'window', + export: 'default', + }, } };