diff --git a/docs/assets/js/common.js b/docs/assets/js/common.js index 3621591ce..d092642b4 100644 --- a/docs/assets/js/common.js +++ b/docs/assets/js/common.js @@ -1,3 +1,4 @@ +/* global Artplayer, monaco */ (function () { Artplayer.DEBUG = true @@ -5,22 +6,22 @@ window.location.href = `./mobile.html${location.search}` } - let $codeMirror = document.querySelector('.codeMirrorWrap') - let $lib = document.querySelector('.libsInput') - let $run = document.querySelector('.run') - let $popups = document.querySelector('.popups') - let $console = document.querySelector('.console') - let $prod = document.querySelector('#prod') - let $ts = document.querySelector('#ts') - let $code = document.querySelector('#code') - let $log = document.querySelector('#log') - let $file = document.querySelector('#file') - let $editor = document.querySelector('#editor') + const $codeMirror = document.querySelector('.codeMirrorWrap') + const $lib = document.querySelector('.libsInput') + const $run = document.querySelector('.run') + const $popups = document.querySelector('.popups') + const $console = document.querySelector('.console') + const $prod = document.querySelector('#prod') + const $ts = document.querySelector('#ts') + const $code = document.querySelector('#code') + const $log = document.querySelector('#log') + const $file = document.querySelector('#file') + const $editor = document.querySelector('#editor') window.consoleLog($console) // Helper functions for localStorage - const getStorageBoolean = (key) => localStorage.getItem(key) === 'true' + const getStorageBoolean = key => localStorage.getItem(key) === 'true' const setStorageBoolean = (key, value) => localStorage.setItem(key, value ? 'true' : 'false') $prod.checked = getStorageBoolean('prod') @@ -37,7 +38,7 @@ } let editor = null - let loadedLibs = [] + let initialization = 0 require.config({ paths: { vs: './assets/js/vs' } }) require(['vs/editor/editor.main'], async () => { monaco.languages.typescript.javascriptDefaults.setDiagnosticsOptions({ @@ -50,7 +51,7 @@ allowNonTsExtensions: true, }) - let libUris = [ + const libUris = [ './assets/ts/artplayer-plugin-ads.d.ts', './assets/ts/artplayer-plugin-ambilight.d.ts', './assets/ts/artplayer-plugin-asr.d.ts', @@ -76,8 +77,8 @@ ] for (let index = 0; index < libUris.length; index++) { - let libUri = libUris[index] - let libSource = await (await fetch(libUri)).text() + const libUri = libUris[index] + const libSource = await (await fetch(libUri)).text() monaco.languages.typescript.javascriptDefaults.addExtraLib(libSource, libUri) monaco.editor.createModel(libSource, 'typescript', monaco.Uri.parse(libUri)) } @@ -108,122 +109,51 @@ }) }) - function getURLParameters(url) { - return (url.match(/([^?=&]+)(=([^&]*))/g) || []).reduce((params, pair) => { - const index = pair.indexOf('=') - params[pair.slice(0, index)] = pair.slice(index + 1) - return params - }, {}) + async function loadLib(libs) { + $lib.value = decodeURIComponent(libs || '') + return window.ArtplayerDocsLoader.loadLibraries(libs) } - function getExt(url) { - if (url.includes('?')) { - return getExt(url.split('?')[0]) - } - - if (url.includes('#')) { - return getExt(url.split('#')[0]) - } - - return url.trim().toLowerCase().split('.').pop() + async function runExample(name, current) { + const text = await window.ArtplayerDocsLoader.exampleSource(name) + if (current !== initialization) + return + editor.setValue(text) + runCode() } - function loadScript(url) { - return new Promise((resolve, reject) => { - const originalDefine = window.define - window.define = undefined - const script = document.createElement('script') - script.type = 'text/javascript' - script.src = url - script.onload = () => { - window.define = originalDefine - resolve(url) - } - script.onerror = () => { - window.define = originalDefine - reject(new Error(`Loading script failed: ${url}`)) - } - document.head.appendChild(script) - }) - } - - function loadStyle(url) { - return new Promise((resolve, reject) => { - const link = document.createElement('link') - link.rel = 'stylesheet' - link.href = url - link.onload = () => resolve(url) - link.onerror = () => reject(new Error(`Loading style failed: ${url}`)) - document.head.appendChild(link) - }) - } - - function loadLib(libs) { - const libPromises = [] - const libsDecoded = decodeURIComponent(libs || '') - const urls = libsDecoded - .split(/\r?\n/) - .filter((url) => url.trim() && !loadedLibs.includes(url)) - - urls.forEach((url) => { - const ext = getExt(url) - if (ext === 'js') { - libPromises.push(loadScript(url)) - } else if (ext === 'css') { - libPromises.push(loadStyle(url)) - } - }) - - $lib.value = libsDecoded - return Promise.all(libPromises) - } - - function runExample(name) { - fetch(`./assets/example/${name}.js`) - .then((response) => { - return response.text() - }) - .then((text) => { - editor.setValue(text) - runCode() - }) - .catch((err) => { - console.error(err) - }) - } - - function loadCode(code, example) { - if (example) { - runExample(example) - } - else if (code) { + function loadCode(code, example, current) { + if (current !== initialization) + return + if (example) + return runExample(example, current) + if (code) { editor.setValue(decodeURIComponent(code).trim()) runCode() } else { - runExample('index') + return runExample('index', current) } } function runCode() { window.dispatchEvent(new Event('artplayer:example:cleanup')) // Destroy all existing instances - Artplayer.instances.slice().forEach((art) => art.destroy(true)) - + Artplayer.instances.slice().forEach(art => art.destroy(true)) + const value = editor.getValue() + // eslint-disable-next-line no-eval -- Preserve the editor's classic-script Run scope for user-provided examples. eval(value) window.art = Artplayer.instances[0] } function initApp() { - const urlParams = getURLParameters(window.location.href) + const current = ++initialization + const urlParams = window.ArtplayerDocsLoader.parameters(window.location.href) const { code, libs, example } = urlParams loadLib(libs) - .then((result) => { - loadedLibs.push(...result) - loadCode(code, example) - }) + .then(() => loadCode(code, example, current)) .catch((err) => { console.error('Failed to initialize app:', err) }) diff --git a/docs/assets/js/loader.js b/docs/assets/js/loader.js new file mode 100644 index 000000000..1488abd38 --- /dev/null +++ b/docs/assets/js/loader.js @@ -0,0 +1,135 @@ +// Generated by yarn build:site-assets. Do not edit. +"use strict"; +(() => { + // packages/artplayer-vitepress/browser/loader.ts + function parameters(url) { + const result = {}; + for (const part of new URL(url).search.slice(1).split("&")) { + const index = part.indexOf("="); + if (index >= 0) + result[part.slice(0, index)] = part.slice(index + 1); + } + return result; + } + function suspendDefine(host) { + const descriptor = Object.getOwnPropertyDescriptor(host, "define"); + if (descriptor?.configurable === false) { + if (!("value" in descriptor) || !descriptor.writable) + throw new Error("Cannot temporarily suspend the AMD loader"); + Object.defineProperty(host, "define", { value: void 0 }); + } else { + Object.defineProperty(host, "define", { value: void 0, configurable: true, writable: true, enumerable: descriptor?.enumerable ?? true }); + } + return () => { + if (descriptor) + Object.defineProperty(host, "define", descriptor); + else Reflect.deleteProperty(host, "define"); + }; + } + function createLibraryLoader(host = window) { + const loaded = /* @__PURE__ */ new Set(); + let queue = Promise.resolve(); + let cancel; + let disposed = false; + const ensureActive = () => { + if (disposed) + throw new Error("Documentation loader was disposed"); + }; + function load(url, script) { + return new Promise((resolve, reject) => { + const element = host.document.createElement(script ? "script" : "link"); + let restore; + let settled = false; + function finish(error) { + if (settled) + return; + settled = true; + element.removeEventListener("load", success); + element.removeEventListener("error", failure); + cancel = void 0; + try { + restore?.(); + } catch (cause) { + error = new Error(`Failed to restore AMD loader: ${String(cause)}`); + } + if (error) { + element.remove(); + reject(error); + } else { + resolve(); + } + } + function success() { + finish(); + } + function failure() { + finish(new Error(`Loading ${script ? "script" : "style"} failed: ${url}`)); + } + element.addEventListener("load", success); + element.addEventListener("error", failure); + cancel = () => finish(new Error(`Cancelled documentation dependency: ${url}`)); + try { + ensureActive(); + if (script) { + restore = suspendDefine(host); + const node = element; + node.type = "text/javascript"; + node.src = url; + node.async = false; + } else { + const node = element; + node.rel = "stylesheet"; + node.href = url; + } + host.document.head.appendChild(element); + } catch (cause) { + finish(cause instanceof Error ? cause : new Error(String(cause))); + } + }); + } + return { + loadLibraries(encoded = "") { + const work = queue.then(async () => { + ensureActive(); + const urls = decodeURIComponent(encoded).split(/\r?\n/).map((url) => url.trim()).filter(Boolean); + const result = []; + for (const url of urls) { + ensureActive(); + const absolute = new URL(url, host.location.href); + const extension = absolute.pathname.toLowerCase().split(".").pop(); + if (extension !== "js" && extension !== "css") + continue; + if (!loaded.has(absolute.href)) { + await load(absolute.href, extension === "js"); + loaded.add(absolute.href); + } + result.push(url); + } + return result; + }); + queue = work.catch(() => { + }); + return work; + }, + dispose() { + disposed = true; + cancel?.(); + loaded.clear(); + } + }; + } + async function exampleSource(name, request = fetch) { + const response = await request(`./assets/example/${name}.js`); + if (!response.ok) + throw new Error(`Loading example failed: ${name} (${response.status})`); + return response.text(); + } + + // packages/artplayer-vitepress/browser/editor-loader.ts + var loader = createLibraryLoader(); + Object.assign(window, { ArtplayerDocsLoader: { ...loader, exampleSource, parameters } }); + window.addEventListener("pagehide", (event) => { + if (!event.persisted) + loader.dispose(); + }); +})(); diff --git a/docs/assets/js/mobile.js b/docs/assets/js/mobile.js index 79f847404..83fc8979e 100644 --- a/docs/assets/js/mobile.js +++ b/docs/assets/js/mobile.js @@ -1,100 +1,142 @@ -(function () { - Artplayer.DEBUG = true; - - function getURLParameters(url) { - return (url.match(/([^?=&]+)(=([^&]*))/g) || []).reduce(function (a, v) { - return (a[v.slice(0, v.indexOf('='))] = v.slice(v.indexOf('=') + 1)), a; - }, {}); +// Generated by yarn build:site-assets. Do not edit. +"use strict"; +(() => { + // packages/artplayer-vitepress/browser/loader.ts + function parameters(url) { + const result = {}; + for (const part of new URL(url).search.slice(1).split("&")) { + const index = part.indexOf("="); + if (index >= 0) + result[part.slice(0, index)] = part.slice(index + 1); } - - function getExt(url) { - if (url.includes('?')) { - return getExt(url.split('?')[0]); - } - - if (url.includes('#')) { - return getExt(url.split('#')[0]); - } - - return url.trim().toLowerCase().split('.').pop(); + return result; + } + function suspendDefine(host) { + const descriptor = Object.getOwnPropertyDescriptor(host, "define"); + if (descriptor?.configurable === false) { + if (!("value" in descriptor) || !descriptor.writable) + throw new Error("Cannot temporarily suspend the AMD loader"); + Object.defineProperty(host, "define", { value: void 0 }); + } else { + Object.defineProperty(host, "define", { value: void 0, configurable: true, writable: true, enumerable: descriptor?.enumerable ?? true }); } - - function loadScript(url) { - return new Promise(function (resolve, reject) { - var define2 = window.define; - window.define = undefined; - var script = document.createElement('script'); - script.type = 'text/javascript'; - script.src = url; - script.onload = function () { - window.define = define2; - resolve(url); - }; - script.onerror = function () { - reject(new Error('Loading script failed:' + url)); - }; - document.querySelector('head').appendChild(script); + return () => { + if (descriptor) + Object.defineProperty(host, "define", descriptor); + else Reflect.deleteProperty(host, "define"); + }; + } + function createLibraryLoader(host = window) { + const loaded = /* @__PURE__ */ new Set(); + let queue = Promise.resolve(); + let cancel; + let disposed = false; + const ensureActive = () => { + if (disposed) + throw new Error("Documentation loader was disposed"); + }; + function load(url, script) { + return new Promise((resolve, reject) => { + const element = host.document.createElement(script ? "script" : "link"); + let restore; + let settled = false; + function finish(error) { + if (settled) + return; + settled = true; + element.removeEventListener("load", success); + element.removeEventListener("error", failure); + cancel = void 0; + try { + restore?.(); + } catch (cause) { + error = new Error(`Failed to restore AMD loader: ${String(cause)}`); + } + if (error) { + element.remove(); + reject(error); + } else { + resolve(); + } + } + function success() { + finish(); + } + function failure() { + finish(new Error(`Loading ${script ? "script" : "style"} failed: ${url}`)); + } + element.addEventListener("load", success); + element.addEventListener("error", failure); + cancel = () => finish(new Error(`Cancelled documentation dependency: ${url}`)); + try { + ensureActive(); + if (script) { + restore = suspendDefine(host); + const node = element; + node.type = "text/javascript"; + node.src = url; + node.async = false; + } else { + const node = element; + node.rel = "stylesheet"; + node.href = url; + } + host.document.head.appendChild(element); + } catch (cause) { + finish(cause instanceof Error ? cause : new Error(String(cause))); + } + }); + } + return { + loadLibraries(encoded = "") { + const work = queue.then(async () => { + ensureActive(); + const urls = decodeURIComponent(encoded).split(/\r?\n/).map((url) => url.trim()).filter(Boolean); + const result = []; + for (const url of urls) { + ensureActive(); + const absolute = new URL(url, host.location.href); + const extension = absolute.pathname.toLowerCase().split(".").pop(); + if (extension !== "js" && extension !== "css") + continue; + if (!loaded.has(absolute.href)) { + await load(absolute.href, extension === "js"); + loaded.add(absolute.href); + } + result.push(url); + } + return result; }); - } - - function loadStyle(url) { - return new Promise(function (resolve, reject) { - var link = document.createElement('link'); - link.rel = 'stylesheet'; - link.href = url; - link.onload = function () { - resolve(url); - }; - link.onerror = function () { - reject(new Error('Loading style failed:' + url)); - }; - document.querySelector('head').appendChild(link); + queue = work.catch(() => { }); - } + return work; + }, + dispose() { + disposed = true; + cancel?.(); + loaded.clear(); + } + }; + } + async function exampleSource(name, request = fetch) { + const response = await request(`./assets/example/${name}.js`); + if (!response.ok) + throw new Error(`Loading example failed: ${name} (${response.status})`); + return response.text(); + } - function loadLib(libs) { - var libPromise = []; - var libsDecode = decodeURIComponent(libs || ''); - libsDecode - .split(/\r?\n/) - .forEach(function (url) { - var ext = getExt(url); - if (ext === 'js') { - libPromise.push(loadScript(url)); - } else if (ext === 'css') { - libPromise.push(loadStyle(url)); - } - }); - return Promise.all(libPromise); - } - - function runExample(name) { - fetch('./assets/example/' + name + '.js') - .then(function (response) { - return response.text(); - }) - .then(function (text) { - eval(text); - }) - } - - function loadCode(code, example) { - if (example) { - runExample(example); - } else if (code) { - eval(decodeURIComponent(code).trim()); - } else { - runExample('mobile'); - } - } - - var _getURLParameters = getURLParameters(window.location.href), - code = _getURLParameters.code, - libs = _getURLParameters.libs; - example = _getURLParameters.example; - - loadLib(libs) - .then(function () { - loadCode(code, example); - }) -})(); \ No newline at end of file + // packages/artplayer-vitepress/browser/mobile.ts + window.Artplayer.DEBUG = true; + var loader = createLibraryLoader(); + window.addEventListener("pagehide", (event) => { + if (!event.persisted) + loader.dispose(); + }); + async function start() { + const { code, libs, example } = parameters(location.href); + await loader.loadLibraries(libs); + const source = example ? await exampleSource(example) : code ? decodeURIComponent(code).trim() : await exampleSource("mobile"); + new Function(source)(); + } + start().catch((error) => console.error("Failed to initialize mobile example:", error)); +})(); diff --git a/docs/index.html b/docs/index.html index 9b9b72ba5..84f291249 100644 --- a/docs/index.html +++ b/docs/index.html @@ -229,6 +229,7 @@
+ `, + })) + await page.goto('https://docs.example/document/start/option.html?x=1#url') + await page.waitForURL( + 'https://docs.example/document/en/start/option.html?x=1#url', + ) + expect(await page.evaluate(() => localStorage.getItem('lang-init'))).toBe( + 'true', + ) + await page.goto('https://docs.example/document/start/option.html') + expect(page.url()).toBe('https://docs.example/document/start/option.html') +}) + +test('a late initial example response cannot overwrite a newer editor Run', async ({ + page, +}) => { + let release + let started + const gate = new Promise((resolve) => { + release = resolve + }) + const requested = new Promise((resolve) => { + started = resolve + }) + await page.route('https://**/*', route => route.fulfill({ body: '' })) + await page.addInitScript(() => { + const original = window.fetch + window.fetch = async (...args) => { + const response = await original(...args) + if (String(args[0]).includes('site.slow.js')) { + const read = response.text.bind(response) + response.text = async () => { + const text = await read() + window.slowReceived = true + return text + } + } + return response + } + }) + await page.route('**/assets/example/site.slow.js', async (route) => { + started() + await gate + await route.fulfill({ + contentType: 'text/javascript', + body: 'window.staleRan = true;', + }) + }) + await page.goto('/?example=site.slow', { waitUntil: 'domcontentloaded' }) + await requested + await page.evaluate(() => { + const model = window.monaco.editor + .getModels() + .find(model => model.getLanguageId() === 'javascript') + model.setValue( + 'window.newerRan = true; var art = new Artplayer({ container: ".artplayer-app", url: "/test/pattern.mp4", muted: true });', + ) + }) + await page.locator('.run').click() + await page.waitForFunction(() => window.art?.isReady) + release() + await page.waitForFunction(() => window.slowReceived) + expect( + await page.evaluate(() => ({ + newer: window.newerRan, + stale: !!window.staleRan, + ready: window.art.isReady, + instances: window.Artplayer.instances.length, + })), + ).toEqual({ newer: true, stale: false, ready: true, instances: 1 }) + await page.evaluate(() => window.art.destroy(true)) +}) + +test('denied storage keeps the requested documentation page without a redirect loop', async ({ + page, +}) => { + const code = fs.readFileSync( + 'packages/artplayer-vitepress/docs/public/main.js', + 'utf8', + ) + await page.addInitScript(() => { + Object.defineProperty(navigator, 'language', { get: () => 'en-US' }) + Object.defineProperty(window, 'localStorage', { + get: () => { + throw new DOMException('Blocked', 'SecurityError') + }, + }) + }) + let requests = 0 + await page.route('https://blocked.example/**', (route) => { + requests++ + return route.fulfill({ + contentType: 'text/html', + body: `Documentation`, + }) + }) + await page.goto('https://blocked.example/document/start/option.html') + expect(page.url()).toBe('https://blocked.example/document/start/option.html') + expect(requests).toBe(1) + expect(await page.evaluate(() => window['run-code-init'])).toBe(true) +}) diff --git a/test/site-loading.test.js b/test/site-loading.test.js new file mode 100644 index 000000000..c1767e888 --- /dev/null +++ b/test/site-loading.test.js @@ -0,0 +1,256 @@ +import assert from 'node:assert/strict' +import { execFileSync } from 'node:child_process' +// eslint-disable-next-line test/no-import-node-test -- Use the repository Node regression runner. +import test from 'node:test' +import vm from 'node:vm' +import { + createLibraryLoader, + exampleSource, + parameters, +} from '../packages/artplayer-vitepress/browser/loader.ts' +import { + languageDestination, + runCodeUrl, +} from '../packages/artplayer-vitepress/browser/navigation.ts' + +const turn = () => new Promise(resolve => setImmediate(resolve)) +function fixture() { + class Element extends EventTarget { + removed = false + remove() { + this.removed = true + } + + succeed() { + this.onload?.() + this.dispatchEvent(new Event('load')) + } + + fail() { + this.onerror?.() + this.dispatchEvent(new Event('error')) + } + } + const nodes = [] + const head = { + appendChild(element) { + nodes.push(element) + }, + } + const host = { + location: { href: 'http://localhost:8082/mobile.html' }, + define: Object.assign(() => {}, { amd: {} }), + document: { + head, + createElement: () => new Element(), + querySelector: () => head, + }, + } + return { host, nodes, head } +} + +test('fixed old mobile loader reproduces failure and overlapping AMD restoration defects', async () => { + const source = execFileSync( + 'git', + [ + 'show', + '76499e7a3e033c8350ff495ba8bfff4ca5c08d25:docs/assets/js/mobile.js', + ], + { encoding: 'utf8' }, + ) + const end = source.indexOf(' var _getURLParameters') + assert(end > 0) + const old = `${source.slice(0, end)} window.legacy = {loadScript, loadLib}; })();` + const failed = fixture() + vm.runInNewContext(old, { + window: failed.host, + document: failed.host.document, + Artplayer: {}, + }) + const failure = failed.host.legacy.loadScript('missing.js') + failed.nodes[0].fail() + await assert.rejects(failure, /Loading script failed/) + assert.equal(failed.host.define, undefined) + const parallel = fixture() + vm.runInNewContext(old, { + window: parallel.host, + document: parallel.host.document, + Artplayer: {}, + }) + const loaded = parallel.host.legacy.loadLib('first.js%0Asecond.js') + assert.equal( + parallel.nodes.length, + 2, + 'Old code starts both scripts before dependencies resolve', + ) + parallel.nodes[0].succeed() + parallel.nodes[1].succeed() + await loaded + assert.equal( + parallel.host.define, + undefined, + 'Second request saved the temporarily cleared define', + ) +}) + +test('shared loader serializes batches, caches successes, retries failures and restores exact AMD descriptor', async () => { + const { host, nodes } = fixture() + const original = Object.getOwnPropertyDescriptor(host, 'define') + const loader = createLibraryLoader(host) + const first = loader.loadLibraries( + encodeURIComponent(' ./first.js \n./second.js'), + ) + const second = loader.loadLibraries( + encodeURIComponent('./first.js\n./third.css'), + ) + await turn() + assert.equal(nodes.length, 1) + assert.equal(host.define, undefined) + nodes[0].succeed() + await turn() + assert.equal(nodes.length, 2) + const rejected = assert.rejects(first, /Loading script failed/) + nodes[1].fail() + await rejected + await turn() + assert.equal(nodes.length, 3) + assert(nodes[1].removed) + assert.deepEqual(Object.getOwnPropertyDescriptor(host, 'define'), original) + nodes[2].succeed() + assert.deepEqual(await second, ['./first.js', './third.css']) + const retry = loader.loadLibraries('./second.js') + await turn() + assert.equal(nodes.length, 4) + nodes[3].succeed() + await retry + assert.deepEqual(Object.getOwnPropertyDescriptor(host, 'define'), original) + await assert.rejects(loader.loadLibraries('%bad-encoding'), URIError) + assert.equal(nodes.length, 4) +}) + +test('loader restores absence/accessors and synchronous failure, cancels pending work and rejects immutable AMD state', async () => { + for (const mode of [ + 'absent', + 'accessor', + 'append-failure', + 'dispose', + 'immutable', + ]) { + const { host, nodes, head } = fixture() + if (mode === 'absent') + Reflect.deleteProperty(host, 'define') + if (mode === 'accessor') { + Object.defineProperty(host, 'define', { + get: () => 42, + configurable: true, + }) + } + if (mode === 'immutable') { + Object.defineProperty(host, 'define', { + value: 42, + configurable: false, + writable: false, + }) + } + if (mode === 'append-failure') { + head.appendChild = () => { + throw new Error('append failed') + } + } + const before = Object.getOwnPropertyDescriptor(host, 'define') + const loader = createLibraryLoader(host) + const pending = loader.loadLibraries('test.js') + const fails = ['append-failure', 'dispose', 'immutable'].includes(mode) + const rejection = fails ? assert.rejects(pending) : null + await turn() + if (mode === 'dispose') + loader.dispose() + else if (!fails) + nodes[0].succeed() + if (rejection) + await rejection + else await pending + assert.deepEqual( + Object.getOwnPropertyDescriptor(host, 'define'), + before, + mode, + ) + if (mode === 'dispose') { + assert(nodes[0].removed) + await assert.rejects(loader.loadLibraries('later.js'), /disposed/) + } + } +}) + +test('URL and example contracts preserve literal plus, last duplicate and explicit example priority inputs', async () => { + assert.deepEqual( + parameters( + 'http://localhost/?code=a+b%2Bc&libs=first&libs=second&example=mobile#ignored', + ), + { code: 'a+b%2Bc', libs: 'second', example: 'mobile' }, + ) + assert.equal( + await exampleSource('mobile', async (url) => { + assert.equal(url, './assets/example/mobile.js') + return new Response('var art = 1') + }), + 'var art = 1', + ) + await assert.rejects( + exampleSource( + 'missing', + async () => new Response('404 HTML', { status: 404 }), + ), + /404/, + ) + const libs = 'https://cdn.example/lib.js?a=1&b=two\n./theme.css' + const code = 'var x = "a+b&你好";' + for (const host of ['localhost', '127.0.0.1', '[::1]']) { + const url = new URL(runCodeUrl({ hostname: host }, libs, code)) + assert.equal(url.host, `${host}:8082`) + assert.equal(url.searchParams.get('libs'), libs) + assert.equal(url.searchParams.get('code'), code) + } + assert.equal( + new URL(runCodeUrl({ hostname: 'artplayer.org' }, '', code)).origin, + 'https://artplayer.org', + ) +}) + +test('language routing preserves translated deep links and explicit choices without redirecting local previews', () => { + const paths = ['', 'index.html', 'start/option.html'] + const choose = (url, lang = 'en-US', initialized = false) => + languageDestination(url, lang, initialized, paths) + assert.deepEqual( + choose('https://artplayer.org/document/start/option.html?x=1#url'), + { + remember: true, + target: 'https://artplayer.org/document/en/start/option.html?x=1#url', + }, + ) + assert.deepEqual( + choose('https://artplayer.org/document/plugin/danmuku.html#x'), + { remember: true, target: 'https://artplayer.org/document/en/' }, + ) + assert.deepEqual( + choose('https://artplayer.org/document/en/start/option.html'), + { remember: true, target: null }, + ) + for (const host of ['localhost', '127.0.0.1', '[::1]']) { + assert.deepEqual(choose(`http://${host}:5173/document/`), { + remember: false, + target: null, + }) + } + for (const [language, initialized] of [ + ['zh-CN', false], + ['ZH-tw', false], + ['en', true], + ['', false], + ]) { + assert.deepEqual( + choose('https://artplayer.org/document/', language, initialized), + { remember: false, target: null }, + ) + } +})