diff --git a/.github/scripts/dependency-cohort.test.mjs b/.github/scripts/dependency-cohort.test.mjs index 652bbb2dc..d4cf73143 100644 --- a/.github/scripts/dependency-cohort.test.mjs +++ b/.github/scripts/dependency-cohort.test.mjs @@ -43,6 +43,9 @@ function angularCohort(manifest) { const fixture = JSON.parse(read('apps/component-demo/test/angular-pptx/package.json')) test('cold Angular consumer pins coherent framework and tooling groups', () => angularCohort(fixture)) +test('cold Angular consumer declares the reviewed Vite security runtime', () => { + assert.equal(fixture.overrides['@angular/build'].vite, '8.3.4') +}) test('independently released tooling patches are accepted', () => { const changed = structuredClone(fixture) for (const section of ['dependencies', 'devDependencies']) @@ -76,6 +79,12 @@ test('missing members, ranges and prerelease versions are rejected', () => { delete changed.devDependencies['@angular/compiler-cli'] assert.throws(() => angularCohort(changed), /Missing Angular/) }) +test('thumbnail declares and physically installs the reviewed happy-dom security runtime', () => { + const manifest = JSON.parse(read('packages/thumbnail/package.json')) + assert.equal(manifest.devDependencies['happy-dom'], '^21.0.4') + const require = createRequire(new URL('../../packages/thumbnail/package.json', import.meta.url)) + assert.equal(require('happy-dom/package.json').version, '21.0.4') +}) test('root and thumbnail declarations agree with the reviewed workspace Vitest cohort', () => { const root = JSON.parse(read('package.json')) const thumbnail = JSON.parse(read('packages/thumbnail/package.json')) diff --git a/.github/scripts/prepare-browser-apt.py b/.github/scripts/prepare-browser-apt.py new file mode 100644 index 000000000..577201e3e --- /dev/null +++ b/.github/scripts/prepare-browser-apt.py @@ -0,0 +1,62 @@ +#!/usr/bin/env python3 +"""Bound Ubuntu downloads on an ephemeral GitHub-hosted browser runner.""" + +import argparse +from pathlib import Path +import re + + +ARCHIVE = "https://archive.ubuntu.com/ubuntu/" +SECURITY = "https://security.ubuntu.com/ubuntu/" +AZURE = "http://azure.archive.ubuntu.com/ubuntu/" +OPTIONS = '''// Browser CI: retain APT's signed metadata and package hash verification. +Acquire::Retries "2"; +Acquire::http::Timeout "30"; +Acquire::https::Timeout "30"; +''' + + +def prepare(mirrors, config): + # These HTTPS alternatives are already in the official runner mirror list: + # https://github.com/actions/runner-images/blob/main/images/ubuntu/scripts/build/configure-apt-sources.sh + # A slow but responsive Azure download does not trigger APT's idle timeout. + # Only change that known mirror list, never sources, suites or signing keys. + for target in (mirrors, config): + if target.is_symlink(): + raise ValueError("APT preparation refuses symbolic links") + if target.exists() and not target.is_file(): + raise ValueError("APT preparation requires regular configuration files") + if not target.parent.is_dir(): + raise ValueError("APT preparation requires existing parent directories") + entries = [] + for line in mirrors.read_text().splitlines(): + line = line.strip() + if not line or line.startswith("#"): + continue + match = re.fullmatch(r"(\S+)\s+priority:\d+", line) + if not match or match[1] not in (AZURE, ARCHIVE, SECURITY): + raise ValueError("Unrecognized runner mirror configuration; no files changed") + entries.append(match[1]) + if len(set(entries)) != len(entries) or not {ARCHIVE, SECURITY}.issubset(entries): + raise ValueError("Expected unique official HTTPS archive alternatives; no files changed") + mirrors.write_text(f"{ARCHIVE}\tpriority:1\n{SECURITY}\tpriority:2\n") + config.write_text(OPTIONS) + print("Ubuntu browser downloads: official HTTPS archives, two retries, 30s idle timeout") + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--mirror-list", type=Path, default=Path("/etc/apt/apt-mirrors.txt")) + parser.add_argument( + "--apt-config", type=Path, + default=Path("/etc/apt/apt.conf.d/80-file-viewer-browser"), + ) + args = parser.parse_args() + try: + prepare(args.mirror_list, args.apt_config) + except (OSError, ValueError) as error: + parser.exit(1, f"APT preparation failed: {error}\n") + + +if __name__ == "__main__": + main() diff --git a/.github/scripts/prepare-browser-apt.test.mjs b/.github/scripts/prepare-browser-apt.test.mjs new file mode 100644 index 000000000..6687cfd00 --- /dev/null +++ b/.github/scripts/prepare-browser-apt.test.mjs @@ -0,0 +1,114 @@ +import assert from 'node:assert/strict' +import { spawnSync } from 'node:child_process' +import { mkdtempSync, readFileSync, rmSync, symlinkSync, writeFileSync } from 'node:fs' +import { tmpdir } from 'node:os' +import { join } from 'node:path' +import test from 'node:test' + +const script = new URL('./prepare-browser-apt.py', import.meta.url).pathname +const archive = 'https://archive.ubuntu.com/ubuntu/' +const security = 'https://security.ubuntu.com/ubuntu/' +const original = `http://azure.archive.ubuntu.com/ubuntu/\tpriority:1\n${archive}\tpriority:2\n${security}\tpriority:3\n` + +function fixture(t, source = original) { + const directory = mkdtempSync(join(tmpdir(), 'file-viewer-apt-')) + t.after(() => rmSync(directory, { recursive: true, force: true })) + const mirrors = join(directory, 'apt-mirrors.txt') + const config = join(directory, '80-file-viewer-browser') + writeFileSync(mirrors, source) + writeFileSync(config, '// original config\n') + return { + directory, + mirrors, + config, + run: () => + spawnSync('python3', [script, '--mirror-list', mirrors, '--apt-config', config], { + encoding: 'utf8' + }) + } +} + +test('the actual runner mirror list selects its official HTTPS alternatives and retains trust inputs', (t) => { + const f = fixture(t) + const sources = join(f.directory, 'ubuntu.sources') + const keyring = join(f.directory, 'ubuntu-archive-keyring.gpg') + const source = + 'URIs: mirror+file:/etc/apt/apt-mirrors.txt\nSuites: noble noble-security\nSigned-By: /usr/share/keyrings/ubuntu-archive-keyring.gpg\n' + writeFileSync(sources, source) + writeFileSync(keyring, 'unchanged signing key fixture') + const result = f.run() + assert.equal(result.status, 0, result.stderr) + assert.equal(readFileSync(f.mirrors, 'utf8'), `${archive}\tpriority:1\n${security}\tpriority:2\n`) + const options = readFileSync(f.config, 'utf8') + assert.match(options, /Acquire::Retries "2"/) + assert.match(options, /Acquire::http::Timeout "30"/) + assert.match(options, /Acquire::https::Timeout "30"/) + assert.doesNotMatch( + options, + /AllowUnauthenticated|AllowInsecure|Verify-Peer|Verify-Host|Check-Valid-Until/ + ) + assert.equal(readFileSync(sources, 'utf8'), source) + assert.equal(readFileSync(keyring, 'utf8'), 'unchanged signing key fixture') + assert.equal(f.run().status, 0) + assert.equal(readFileSync(f.mirrors, 'utf8'), `${archive}\tpriority:1\n${security}\tpriority:2\n`) +}) + +for (const [name, source] of [ + ['unknown mirror', original + 'https://example.invalid/ubuntu/ priority:4\n'], + ['missing HTTPS archive', `${security} priority:1\n`], + ['insecure alternative', original.replace(archive, 'http://archive.ubuntu.com/ubuntu/')], + ['duplicate archive', original + `${archive} priority:4\n`], + ['unknown mirror metadata', original.replace('priority:2', 'priority:2 arch:amd64')] +]) { + test(`an ${name} fails before changing either configuration file`, (t) => { + const f = fixture(t, source) + assert.notEqual(f.run().status, 0) + assert.equal(readFileSync(f.mirrors, 'utf8'), source) + assert.equal(readFileSync(f.config, 'utf8'), '// original config\n') + }) +} + +test('a symlink cannot redirect the privileged config write', (t) => { + const f = fixture(t) + const outside = join(f.directory, 'unrelated-config') + writeFileSync(outside, 'unchanged') + rmSync(f.config) + symlinkSync(outside, f.config) + assert.notEqual(f.run().status, 0) + assert.equal(readFileSync(outside, 'utf8'), 'unchanged') + assert.equal(readFileSync(f.mirrors, 'utf8'), original) +}) + +test('all browser CI entrypoints prepare APT before a bounded, unchanged browser install', () => { + for (const file of [ + 'public-ci.yml', + 'geo3d-focused-validation.yml', + 'geo3d-branch-validation.yml' + ]) { + const source = readFileSync(new URL(`../workflows/${file}`, import.meta.url), 'utf8') + const steps = source.split(/\n\s+- name: /) + for (const [index, step] of steps.entries()) { + if (!step.includes('pnpm exec playwright install --with-deps')) continue + assert.match(steps[index - 1], /sudo python3 \.github\/scripts\/prepare-browser-apt\.py/) + assert.match(step, /timeout-minutes: 10/) + assert.match(step, /install --with-deps chromium (?:firefox )?webkit/) + } + } + const workflow = readFileSync(new URL('../workflows/public-ci.yml', import.meta.url), 'utf8') + const fonts = workflow.split('- name: Install the Noto CJK font cohort')[1]?.split('- name:')[0] + assert.ok(fonts) + assert.match(fonts, /timeout-minutes: 10/) + assert.match(fonts, /dpkg-query.*fonts-noto-cjk/) + assert.match(fonts, /apt-get install -y --no-install-recommends fonts-noto-cjk/) + assert.match(fonts, /grep -q 'Noto Serif CJK SC'/) + assert.match(fonts, /font-cohort\.log/) + const browsers = workflow + .split('- name: Verify PDF hand tool and native DOC font table in Chromium and WebKit')[1] + ?.split('- name:')[0] + assert.match(browsers, /renderer-pdf verify:hand-tool/) + assert.match(browsers, /@file-viewer\/doc verify:font-table-browser/) + assert.ok( + workflow.indexOf('- name: Install the Noto CJK font cohort') < + workflow.indexOf('- name: Verify PDF hand tool') + ) +}) diff --git a/.github/workflows/geo3d-branch-validation.yml b/.github/workflows/geo3d-branch-validation.yml index f54e70e27..79bf9722c 100644 --- a/.github/workflows/geo3d-branch-validation.yml +++ b/.github/workflows/geo3d-branch-validation.yml @@ -87,9 +87,14 @@ jobs: id: focused run: bash "$RUNNER_TEMP/geo3d-record.sh" geo3d-entry pnpm test:geo3d-entry + - name: Configure bounded Ubuntu archive downloads + if: ${{ !cancelled() && steps.install.outcome == 'success' }} + run: sudo python3 .github/scripts/prepare-browser-apt.py + - name: Install Chromium and WebKit id: browsers if: ${{ !cancelled() && steps.install.outcome == 'success' }} + timeout-minutes: 10 run: bash "$RUNNER_TEMP/geo3d-record.sh" playwright-install pnpm exec playwright install --with-deps chromium webkit - name: Focused Geo3D browser smoke diff --git a/.github/workflows/geo3d-focused-validation.yml b/.github/workflows/geo3d-focused-validation.yml index 072b6d6e9..0f46ba1d1 100644 --- a/.github/workflows/geo3d-focused-validation.yml +++ b/.github/workflows/geo3d-focused-validation.yml @@ -53,9 +53,13 @@ jobs: run: | mkdir -p output/geo3d-focused pnpm test:geo3d-entry 2>&1 | tee output/geo3d-focused/entry.log + - name: Configure bounded Ubuntu archive downloads + if: ${{ !cancelled() && steps.install.outcome == 'success' }} + run: sudo python3 .github/scripts/prepare-browser-apt.py - name: Install Chromium id: browser if: ${{ !cancelled() && steps.install.outcome == 'success' }} + timeout-minutes: 10 run: pnpm exec playwright install --with-deps chromium webkit - name: Browser datasets and resource ownership if: >- diff --git a/.github/workflows/public-ci.yml b/.github/workflows/public-ci.yml index e5cbcc5c9..66c28e0d2 100644 --- a/.github/workflows/public-ci.yml +++ b/.github/workflows/public-ci.yml @@ -56,9 +56,14 @@ jobs: if: steps.impact.outputs.runtime != 'false' run: pnpm --filter @file-viewer/core --filter @file-viewer/renderer-text --filter @file-viewer/capability-mermaid --filter @file-viewer/renderer-drawing --filter @file-viewer/renderer-dicom build + - name: Configure bounded Ubuntu archive downloads + if: steps.impact.outputs.runtime != 'false' + run: sudo python3 .github/scripts/prepare-browser-apt.py + - name: Install Chromium and WebKit id: browsers if: steps.impact.outputs.runtime != 'false' + timeout-minutes: 10 run: pnpm exec playwright install --with-deps chromium webkit - name: Verify DICOM cold consumers, six transfer syntaxes and visible PNGs @@ -200,8 +205,13 @@ jobs: if: steps.impact.outputs.runtime != 'false' run: pnpm type-check + - name: Configure bounded Ubuntu archive downloads + if: steps.impact.outputs.runtime != 'false' + run: sudo python3 .github/scripts/prepare-browser-apt.py + - name: Install Chromium, Firefox and WebKit if: steps.impact.outputs.runtime != 'false' + timeout-minutes: 10 run: pnpm exec playwright install --with-deps chromium firefox webkit - name: Run the issue regression gates @@ -279,17 +289,24 @@ jobs: if: steps.impact.outputs.runtime != 'false' run: pnpm --filter @file-viewer/vite-plugin verify:lite-consumer - - name: Verify PDF hand tool and native DOC font table in Chromium and WebKit + - name: Install the Noto CJK font cohort if: steps.impact.outputs.runtime != 'false' + timeout-minutes: 10 run: | # WenQuanYi's CJK vertical advances can be zero in Chromium. The # geometry contract needs the same reviewed Noto cohort as DOCX CI. - sudo apt-get update - sudo apt-get install -y --no-install-recommends fonts-noto-cjk + if ! dpkg-query -W -f='${Status}' fonts-noto-cjk 2>/dev/null | grep -Fxq 'install ok installed'; then + sudo apt-get update + sudo apt-get install -y --no-install-recommends fonts-noto-cjk + fi mkdir -p output/doc-font-table fc-match -f '%{family}\n' 'Noto Serif CJK SC' | tee output/doc-font-table/font-cohort.log grep -q 'Noto Serif CJK SC' output/doc-font-table/font-cohort.log dpkg-query -W fonts-noto-cjk >> output/doc-font-table/font-cohort.log + + - name: Verify PDF hand tool and native DOC font table in Chromium and WebKit + if: steps.impact.outputs.runtime != 'false' + run: | pnpm --filter @file-viewer/renderer-pdf verify:hand-tool pnpm --filter @file-viewer/doc verify:font-table-browser @@ -376,10 +393,18 @@ jobs: if: steps.impact.outputs.runtime != 'false' run: pnpm verify:chm-renderer - - name: Verify Vue 3 toolbar in Chromium + - name: Verify Vue 3 and Web toolbar in Chromium and WebKit if: steps.impact.outputs.runtime != 'false' run: pnpm verify:vue3-toolbar-browser + - name: Retain zoom toolbar browser evidence + if: always() && steps.impact.outputs.runtime != 'false' + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 + with: + name: zoom-toolbar-${{ github.sha }} + path: output/zoom-toolbar-browser/** + retention-days: 7 + - name: Verify untrusted renderer markup sanitization if: steps.impact.outputs.runtime != 'false' run: pnpm verify:renderer-sanitization diff --git a/apps/component-demo/scripts/verify-angular-pptx-consumer.mjs b/apps/component-demo/scripts/verify-angular-pptx-consumer.mjs index dca74baf4..fb7e5bb60 100644 --- a/apps/component-demo/scripts/verify-angular-pptx-consumer.mjs +++ b/apps/component-demo/scripts/verify-angular-pptx-consumer.mjs @@ -66,7 +66,8 @@ const report = { angular: consumerRequire('@angular/core/package.json').version, tooling: { build: consumerRequire('@angular/build/package.json').version, - cli: consumerRequire('@angular/cli/package.json').version + cli: consumerRequire('@angular/cli/package.json').version, + vite: createRequire(consumerRequire.resolve('@angular/build/package.json'))('vite/package.json').version }, baseHref: '/ui/', expectedSlides, @@ -79,6 +80,7 @@ const acceptedFixture = JSON.parse( assert.equal(report.angular, acceptedFixture.dependencies['@angular/core']) assert.equal(report.tooling.build, acceptedFixture.devDependencies['@angular/build']) assert.equal(report.tooling.cli, acceptedFixture.devDependencies['@angular/cli']) +assert.equal(report.tooling.vite, acceptedFixture.overrides['@angular/build'].vite) let dev, server, page, diff --git a/apps/component-demo/scripts/verify-vue3-toolbar-browser.mjs b/apps/component-demo/scripts/verify-vue3-toolbar-browser.mjs index 0a849588d..76aa85abf 100644 --- a/apps/component-demo/scripts/verify-vue3-toolbar-browser.mjs +++ b/apps/component-demo/scripts/verify-vue3-toolbar-browser.mjs @@ -4,6 +4,7 @@ import { createServer } from 'node:http' import { createRequire } from 'node:module' import { delimiter, extname, join, normalize, resolve, sep } from 'node:path' import { pathToFileURL } from 'node:url' +import { verifyZoomToolbarBrowser } from './verify-zoom-toolbar-browser.mjs' // The verifier runs from the repository root in local and public CI. const distDir = resolve('apps/component-demo/dist') @@ -15,11 +16,11 @@ const importPlaywright = async () => { try { return await import('playwright') } catch (originalError) { - const candidatePaths = process.env.PATH - ?.split(delimiter) - .filter(pathEntry => pathEntry.endsWith(`${sep}node_modules${sep}.bin`)) - .map(binDir => resolve(binDir, '..')) - .filter(pathEntry => existsSync(pathEntry)) || [] + const candidatePaths = + process.env.PATH?.split(delimiter) + .filter((pathEntry) => pathEntry.endsWith(`${sep}node_modules${sep}.bin`)) + .map((binDir) => resolve(binDir, '..')) + .filter((pathEntry) => existsSync(pathEntry)) || [] for (const candidatePath of candidatePaths) { try { @@ -40,10 +41,12 @@ const importPlaywright = async () => { } const playwrightModule = await importPlaywright() -const { chromium } = playwrightModule.chromium ? playwrightModule : playwrightModule.default +const { chromium, webkit } = playwrightModule.chromium ? playwrightModule : playwrightModule.default if (!existsSync(entryPath)) { - throw new Error(`Built Vue 3 component demo is missing: ${entryPath}. Run pnpm build:component-demo first.`) + throw new Error( + `Built Vue 3 component demo is missing: ${entryPath}. Run pnpm build:component-demo first.` + ) } const contentTypes = { @@ -53,6 +56,9 @@ const contentTypes = { '.md': 'text/markdown; charset=utf-8', '.mjs': 'text/javascript; charset=utf-8', '.png': 'image/png', + '.pdf': 'application/pdf', + '.woff': 'font/woff', + '.woff2': 'font/woff2', '.svg': 'image/svg+xml', '.wasm': 'application/wasm' } @@ -60,9 +66,8 @@ const contentTypes = { const server = createServer((request, response) => { const requestUrl = new URL(request.url || '/', 'http://127.0.0.1') const cleanPath = decodeURIComponent(requestUrl.pathname) - const candidate = cleanPath === '/' - ? entryPath - : join(distDir, normalize(cleanPath).replace(/^[/\\]+/, '')) + const candidate = + cleanPath === '/' ? entryPath : join(distDir, normalize(cleanPath).replace(/^[/\\]+/, '')) const filePath = existsSync(candidate) && statSync(candidate).isFile() ? candidate : entryPath response.writeHead(200, { 'Content-Type': contentTypes[extname(filePath).toLowerCase()] || 'application/octet-stream', @@ -84,10 +89,10 @@ try { browser = await chromium.launch({ headless: true }) const page = await browser.newPage({ viewport: { width: 1280, height: 800 } }) const errors = [] - page.on('console', message => { + page.on('console', (message) => { if (message.type() === 'error') errors.push(message.text()) }) - page.on('pageerror', error => errors.push(error.message)) + page.on('pageerror', (error) => errors.push(error.message)) await page.goto(`${baseUrl}/vue3.html?smoke=toolbar-slots`, { waitUntil: 'domcontentloaded', @@ -107,7 +112,7 @@ try { const toolbar = root?.querySelector('.viewer-actions') return { hasOpenShadowRoot: !!root, - childOrder: Array.from(toolbar?.children || []).map(element => { + childOrder: Array.from(toolbar?.children || []).map((element) => { if (element.querySelector('[data-testid="vue3-toolbar-start"]')) return 'start' if (element.classList.contains('viewer-search-actions')) return 'search' if (element.querySelector('[data-testid="vue3-toolbar-end"]')) return 'end' @@ -115,24 +120,48 @@ try { }) } }) - assert.equal(shadowContract.hasOpenShadowRoot, true, 'The default Vue 3 integration must use its open Shadow DOM path.') + assert.equal( + shadowContract.hasOpenShadowRoot, + true, + 'The default Vue 3 integration must use its open Shadow DOM path.' + ) assert.deepEqual( shadowContract.childOrder, ['start', 'search', 'end'], 'toolbar-start and toolbar-end must wrap the built-in search group in Shadow DOM.' ) - assert.equal(await startSlot.getAttribute('data-api-search'), 'true', 'toolbar-start did not receive the public API.') - assert.equal(await endSlot.getAttribute('data-api-search'), 'true', 'toolbar-end did not receive the public API.') - assert.equal(await startSlot.getAttribute('data-download-available'), 'true', 'Slot availability did not update after render.') - assert.ok(Number(await startSlot.getAttribute('data-zoom-scale')) > 0, 'Slot zoom state is invalid.') + assert.equal( + await startSlot.getAttribute('data-api-search'), + 'true', + 'toolbar-start did not receive the public API.' + ) + assert.equal( + await endSlot.getAttribute('data-api-search'), + 'true', + 'toolbar-end did not receive the public API.' + ) + assert.equal( + await startSlot.getAttribute('data-download-available'), + 'true', + 'Slot availability did not update after render.' + ) + assert.ok( + Number(await startSlot.getAttribute('data-zoom-scale')) > 0, + 'Slot zoom state is invalid.' + ) await startSlot.click() await page.locator('.flyfish-search-match--active').first().waitFor({ state: 'visible', timeout }) - await page.waitForFunction(() => { - const button = document.querySelector('#vue3-viewer .file-viewer-vue3-shadow-host') - ?.shadowRoot?.querySelector('[data-testid="vue3-toolbar-start"]') - return Number(button?.getAttribute('data-search-total') || 0) > 0 - }, undefined, { timeout }) + await page.waitForFunction( + () => { + const button = document + .querySelector('#vue3-viewer .file-viewer-vue3-shadow-host') + ?.shadowRoot?.querySelector('[data-testid="vue3-toolbar-start"]') + return Number(button?.getAttribute('data-search-total') || 0) > 0 + }, + undefined, + { timeout } + ) await endSlot.click() assert.equal( await page.evaluate(() => document.body.dataset.vue3ToolbarEndClicked), @@ -156,11 +185,20 @@ try { 'A search-only toolbar must be absent when the active renderer cannot search.' ) - const actionableErrors = errors.filter(message => !/favicon|ResizeObserver loop/i.test(message)) + const actionableErrors = errors.filter((message) => !/favicon|ResizeObserver loop/i.test(message)) assert.deepEqual(actionableErrors, [], `Browser console errors:\n${actionableErrors.join('\n')}`) - console.log('[verify-vue3-toolbar-browser] Vue 3 full, Shadow DOM slots, reactive slot API and renderer-bounded search verified.') + console.log( + '[verify-vue3-toolbar-browser] Vue 3 full, Shadow DOM slots, reactive slot API and renderer-bounded search verified.' + ) + await verifyZoomToolbarBrowser({ browser, baseUrl, engine: 'chromium' }) + const webkitBrowser = await webkit.launch({ headless: true }) + try { + await verifyZoomToolbarBrowser({ browser: webkitBrowser, baseUrl, engine: 'webkit' }) + } finally { + await webkitBrowser.close() + } } finally { await browser?.close() - await new Promise(resolveClose => server.close(resolveClose)) + await new Promise((resolveClose) => server.close(resolveClose)) } diff --git a/apps/component-demo/scripts/verify-zoom-toolbar-browser.mjs b/apps/component-demo/scripts/verify-zoom-toolbar-browser.mjs new file mode 100644 index 000000000..8e25d9cfd --- /dev/null +++ b/apps/component-demo/scripts/verify-zoom-toolbar-browser.mjs @@ -0,0 +1,294 @@ +import assert from 'node:assert/strict' +import { mkdir, writeFile } from 'node:fs/promises' +import { resolve } from 'node:path' + +export async function verifyZoomToolbarBrowser({ browser, baseUrl, engine }) { + const directory = resolve('output/zoom-toolbar-browser', engine) + await mkdir(directory, { recursive: true }) + const report = { + engine, + browser: browser.version(), + cases: [], + externalRequests: [], + pageErrors: [], + passed: false + } + const pages = [] + const within = async (promise, label) => { + let timer + try { + return await Promise.race([ + promise, + new Promise((_, reject) => { + timer = setTimeout(() => reject(new Error(`Timed out: ${label}`)), 15000) + }) + ]) + } finally { + clearTimeout(timer) + } + } + const open = async (path) => { + const page = await browser.newPage({ viewport: { width: 1280, height: 800 }, locale: 'en-US' }) + pages.push(page) + page.on('pageerror', (error) => report.pageErrors.push(error.message)) + await page.route('**/*', (route) => { + const url = route.request().url() + if (url.startsWith(baseUrl + '/') || /^(?:blob:|data:)/.test(url)) return route.continue() + report.externalRequests.push(url) + return route.abort() + }) + await page.goto(baseUrl + path, { waitUntil: 'domcontentloaded' }) + return page + } + const frames = (page) => + page.evaluate( + () => + new Promise((resolveFrame) => + requestAnimationFrame(() => requestAnimationFrame(resolveFrame)) + ) + ) + const imageReady = async (page) => { + const image = page.locator('.image-viewer img').first() + await image.waitFor({ state: 'visible' }) + await image.evaluate((image) => image.decode()) + return image + } + try { + for (const integration of ['web', 'vue']) { + const page = await open( + integration === 'web' + ? '/custom-element.html?url=/example/pic.png' + : '/vue3.html?smoke=zoom-boundary&url=/example/pic.png' + ) + await imageReady(page) + const group = page + .locator(integration === 'web' ? '.file-viewer-web-toolbar-group' : '.viewer-zoom-actions') + .first() + const plus = + integration === 'web' + ? group.locator('button').filter({ hasText: /^\+$/ }) + : group.locator('[part~="zoom-in-button"]') + const minus = + integration === 'web' + ? group.locator('button').filter({ hasText: /^-$/ }) + : group.locator('[part~="zoom-out-button"]') + const reset = + integration === 'web' + ? group.locator('button').filter({ hasText: /^1:1$/ }) + : group.locator('[part~="zoom-reset-button"]') + const meter = group.locator( + integration === 'web' ? '.file-viewer-web-zoom-meter' : '.viewer-zoom-meter' + ) + await plus.waitFor({ state: 'visible' }) + await frames(page) + const originalLabel = await meter.textContent() + assert.equal( + await reset.count(), + 1, + `${integration}: reset slot must exist before the first zoom` + ) + assert.equal(await reset.isDisabled(), true) + const origin = { plus: await plus.boundingBox(), minus: await minus.boundingBox() } + assert.ok(origin.plus && origin.minus) + for (let click = 0; click < 48; click += 1) + await page.mouse.click( + origin.plus.x + origin.plus.width / 2, + origin.plus.y + origin.plus.height / 2 + ) + await frames(page) + assert.equal(await plus.isVisible(), true) + assert.equal( + await plus.isDisabled(), + true, + `${integration}: repeated fixed-position clicks must remain at maximum zoom` + ) + assert.equal((await meter.textContent()).trim(), '500%') + assert.deepEqual( + await plus.boundingBox(), + origin.plus, + `${integration}: zoom-in position changed` + ) + await page.screenshot({ path: `${directory}/${integration}-maximum.png` }) + for (let click = 0; click < 48; click += 1) + await page.mouse.click( + origin.minus.x + origin.minus.width / 2, + origin.minus.y + origin.minus.height / 2 + ) + await frames(page) + assert.equal(await minus.isVisible(), true) + assert.equal( + await minus.isDisabled(), + true, + `${integration}: minimum zoom button must stay disabled in place` + ) + assert.deepEqual( + await minus.boundingBox(), + origin.minus, + `${integration}: zoom-out position changed` + ) + await page.screenshot({ path: `${directory}/${integration}-minimum.png` }) + await reset.click() + await frames(page) + assert.equal(await meter.textContent(), originalLabel) + report.cases.push({ + integration, + name: 'fixed-position rapid clicks at both limits and reset', + passed: true + }) + + if (integration === 'web') { + await page.evaluate(() => { + const element = document.getElementById('custom-element-viewer') + element.options = { + ...element.options, + toolbar: { position: 'bottom-right', items: { 'zoom-reset': false } } + } + }) + await reset.waitFor({ state: 'detached' }) + assert.equal(await plus.isVisible(), true) + assert.equal(await minus.isVisible(), true) + assert.equal(await meter.evaluate((element) => element.tagName), 'SPAN') + report.cases.push({ + integration, + name: 'hide reset with existing items policy', + passed: true + }) + + let releaseOld + let oldRequested + const requested = new Promise((resolveRequest) => { + oldRequested = resolveRequest + }) + const released = new Promise((resolveRelease) => { + releaseOld = resolveRelease + }) + let settleOld + const oldSettled = new Promise((resolveOld) => { + settleOld = resolveOld + }) + await page.route('**/delayed398.svg', async (route) => { + oldRequested() + await released + try { + await route.fulfill({ + contentType: 'image/svg+xml', + body: '' + }) + } catch (error) { + // The old request may already have been aborted by the new load. + assert.match(String(error), /abort|closed|handled|invalid|cancel/i) + } finally { + settleOld() + } + }) + const width = await page + .locator('.image-viewer img') + .first() + .evaluate((image) => { + image.dataset.github398Old = 'true' + return image.naturalWidth + }) + assert.ok(width > 2) + await page.evaluate(() => { + const element = document.getElementById('custom-element-viewer') + element.filename = 'delayed398.svg' + element.src = '/delayed398.svg' + }) + await within(requested, 'old source request') + await page.evaluate(() => { + const element = document.getElementById('custom-element-viewer') + element.filename = 'pic.png' + element.src = '/example/pic.png' + }) + const replacement = page.locator('.image-viewer img:not([data-github398-old])').first() + await replacement.waitFor({ state: 'visible' }) + await replacement.evaluate((image) => image.decode()) + assert.equal(await replacement.evaluate((image) => image.naturalWidth), width) + await replacement.evaluate((image) => { + image.dataset.github398New = 'true' + }) + releaseOld() + await within(oldSettled, 'old source cancellation or response') + await frames(page) + assert.equal( + await page + .locator('.image-viewer img') + .first() + .evaluate((image) => image.naturalWidth), + width + ) + assert.equal( + await page.locator('.image-viewer img').first().getAttribute('data-github398-new'), + 'true' + ) + assert.equal(await plus.isVisible(), true) + report.cases.push({ + integration, + name: 'cancel old source and ignore its delayed response', + passed: true + }) + } + } + const hiddenReset = await open('/vue3.html?smoke=zoom-reset-hidden&url=/example/pic.png') + await imageReady(hiddenReset) + assert.equal(await hiddenReset.locator('[part~="zoom-reset-button"]').count(), 0) + assert.equal( + await hiddenReset.locator('.viewer-zoom-meter').evaluate((element) => element.tagName), + 'SPAN' + ) + assert.equal(await hiddenReset.locator('[part~="zoom-in-button"]').isVisible(), true) + report.cases.push({ + integration: 'vue', + name: 'hide reset with existing items policy', + passed: true + }) + + const denied = await open('/vue3.html?smoke=zoom-in-denied&url=/example/pic.png') + await imageReady(denied) + assert.equal(await denied.locator('[part~="zoom-in-button"]').count(), 0) + assert.equal(await denied.locator('[part~="zoom-out-button"]').isVisible(), true) + report.cases.push({ + integration: 'vue', + name: 'preserve explicit zoom permission hiding', + passed: true + }) + + const pdf = await open('/vue3.html?smoke=zoom-pdf-hidden&url=/example/pdf.pdf') + await pdf + .locator('.pdfViewer .page canvas') + .first() + .waitFor({ state: 'visible', timeout: 60000 }) + assert.equal(await pdf.locator('.pdf-toolbar-group--zoom').count(), 0) + assert.equal(await pdf.locator('.pdf-toolbar').isVisible(), true) + assert.equal(await pdf.locator('.pdf-toolbar-group--rotate button').count(), 2) + await pdf.locator('.pdf-toolbar-group--rotate button').last().click() + await pdf.locator('.pdf-rotation-meter').filter({ hasText: '90°' }).waitFor() + const total = Number( + (await pdf.locator('.pdf-page-meter').textContent()).split('/').at(-1)?.trim() + ) + assert.ok(total > 0) + if (total > 1) { + await pdf.locator('.pdf-toolbar-group').first().locator('button').last().click() + await pdf.locator('.pdf-page-meter strong').filter({ hasText: /^2$/ }).waitFor() + } + await pdf.screenshot({ path: `${directory}/pdf-zoom-hidden.png` }) + report.cases.push({ + integration: 'vue/pdf', + name: 'zoom-only hiding preserves working navigation and rotation', + pages: total, + passed: true + }) + assert.deepEqual(report.externalRequests, []) + assert.deepEqual(report.pageErrors, []) + report.passed = true + console.log( + `[verify-zoom-toolbar-browser] ${engine}: ${report.cases.length} real UI cases passed.` + ) + } catch (error) { + report.error = error instanceof Error ? error.stack : String(error) + throw error + } finally { + await writeFile(`${directory}/report.json`, JSON.stringify(report, null, 2) + '\n') + await Promise.all(pages.map((page) => page.close())) + } +} diff --git a/apps/component-demo/src/vue3-demo.ts b/apps/component-demo/src/vue3-demo.ts index bda9c5fc6..90149a86a 100644 --- a/apps/component-demo/src/vue3-demo.ts +++ b/apps/component-demo/src/vue3-demo.ts @@ -11,11 +11,13 @@ import './styles.css' const host = document.getElementById('vue3-viewer') const params = new URLSearchParams(window.location.search) const toolbarSmoke = params.get('smoke') -const defaultUrl = toolbarSmoke === 'toolbar-slots' - ? '/example/markdown.md' - : toolbarSmoke === 'search-boundary' - ? '/example/pic.png' - : '/example/word.docx' +const zoomSmoke = toolbarSmoke?.startsWith('zoom-') ?? false +const defaultUrl = + toolbarSmoke === 'toolbar-slots' + ? '/example/markdown.md' + : toolbarSmoke === 'search-boundary' + ? '/example/pic.png' + : '/example/word.docx' const demoSource = getDemoSource(defaultUrl) if (!host) { @@ -33,40 +35,67 @@ const searchOnlyToolbar = { const options: ViewerOptions = { theme: 'light', - toolbar: toolbarSmoke ? searchOnlyToolbar : { position: 'bottom-right' } + toolbar: zoomSmoke + ? { + ...searchOnlyToolbar, + zoom: true, + items: toolbarSmoke === 'zoom-reset-hidden' ? { 'zoom-reset': false } : undefined, + permissions: toolbarSmoke === 'zoom-in-denied' ? { 'zoom-in': false } : undefined + } + : toolbarSmoke + ? searchOnlyToolbar + : { position: 'bottom-right' }, + pdf: toolbarSmoke === 'zoom-pdf-hidden' ? { zoomToolbar: false } : undefined } -const toolbarSlots = toolbarSmoke === 'toolbar-slots' - ? { - 'toolbar-start': (slotProps: FileViewerToolbarSlotProps) => h('button', { - type: 'button', - 'data-testid': 'vue3-toolbar-start', - 'data-api-search': String(typeof slotProps.api.searchDocument === 'function'), - 'data-download-available': String(slotProps.availability.download), - 'data-zoom-scale': String(slotProps.zoomState.scale), - 'data-search-total': String(slotProps.searchState.total), - onClick: () => slotProps.api.searchDocument('Markdown') - }, 'Search from start slot'), - 'toolbar-end': (slotProps: FileViewerToolbarSlotProps) => h('button', { - type: 'button', - 'data-testid': 'vue3-toolbar-end', - 'data-api-search': String(typeof slotProps.api.searchDocument === 'function'), - 'data-search-total': String(slotProps.searchState.total), - onClick: () => { - document.body.dataset.vue3ToolbarEndClicked = 'true' - } - }, 'End slot') - } - : undefined +const toolbarSlots = + toolbarSmoke === 'toolbar-slots' + ? { + 'toolbar-start': (slotProps: FileViewerToolbarSlotProps) => + h( + 'button', + { + type: 'button', + 'data-testid': 'vue3-toolbar-start', + 'data-api-search': String(typeof slotProps.api.searchDocument === 'function'), + 'data-download-available': String(slotProps.availability.download), + 'data-zoom-scale': String(slotProps.zoomState.scale), + 'data-search-total': String(slotProps.searchState.total), + onClick: () => slotProps.api.searchDocument('Markdown') + }, + 'Search from start slot' + ), + 'toolbar-end': (slotProps: FileViewerToolbarSlotProps) => + h( + 'button', + { + type: 'button', + 'data-testid': 'vue3-toolbar-end', + 'data-api-search': String(typeof slotProps.api.searchDocument === 'function'), + 'data-search-total': String(slotProps.searchState.total), + onClick: () => { + document.body.dataset.vue3ToolbarEndClicked = 'true' + } + }, + 'End slot' + ) + } + : undefined createApp({ render() { - return h(FileViewer, { - url: demoSource.url, - filename: demoSource.filename, - options - }, toolbarSlots) + return h( + FileViewer, + { + url: demoSource.url, + filename: demoSource.filename, + options + }, + toolbarSlots + ) } -}).use(FileViewerPlugin).mount(host) +}) + .use(FileViewerPlugin) + .mount(host) document.body.setAttribute('data-component', 'vue3') diff --git a/apps/component-demo/test/angular-pptx/package.json b/apps/component-demo/test/angular-pptx/package.json index be95832a5..93c3a2f42 100644 --- a/apps/component-demo/test/angular-pptx/package.json +++ b/apps/component-demo/test/angular-pptx/package.json @@ -7,10 +7,10 @@ "build": "ng build" }, "dependencies": { - "@angular/common": "22.2.1", - "@angular/compiler": "22.2.1", - "@angular/core": "22.2.1", - "@angular/platform-browser": "22.2.1", + "@angular/common": "22.2.3", + "@angular/compiler": "22.2.3", + "@angular/core": "22.2.3", + "@angular/platform-browser": "22.2.3", "@file-viewer/web": "3.2.2", "@file-viewer/preset-office": "3.2.2", "file-viewer-copy-assets": "3.2.2", @@ -18,14 +18,15 @@ "tslib": "2.8.1" }, "devDependencies": { - "@angular/build": "22.2.2", - "@angular/cli": "22.2.2", - "@angular/compiler-cli": "22.2.1", + "@angular/build": "22.2.3", + "@angular/cli": "22.2.3", + "@angular/compiler-cli": "22.2.3", "typescript": "6.0.3" }, "overrides": { "@angular/build": { - "piscina": "5.3.2" + "piscina": "5.3.2", + "vite": "8.3.4" }, "@angular/cli": { "@modelcontextprotocol/sdk": "1.31.0" diff --git a/apps/component-demo/test/evidence/github-398/chromium-vue-minimum.png b/apps/component-demo/test/evidence/github-398/chromium-vue-minimum.png new file mode 100644 index 000000000..cd15c6a73 Binary files /dev/null and b/apps/component-demo/test/evidence/github-398/chromium-vue-minimum.png differ diff --git a/apps/component-demo/test/evidence/github-398/chromium-web-maximum.png b/apps/component-demo/test/evidence/github-398/chromium-web-maximum.png new file mode 100644 index 000000000..18d4822af Binary files /dev/null and b/apps/component-demo/test/evidence/github-398/chromium-web-maximum.png differ diff --git a/apps/component-demo/test/evidence/github-398/webkit-pdf-zoom-hidden.png b/apps/component-demo/test/evidence/github-398/webkit-pdf-zoom-hidden.png new file mode 100644 index 000000000..243eaddde Binary files /dev/null and b/apps/component-demo/test/evidence/github-398/webkit-pdf-zoom-hidden.png differ diff --git a/package.json b/package.json index 9a08bdca7..a9d519834 100644 --- a/package.json +++ b/package.json @@ -62,13 +62,14 @@ "test:spreadsheet-github-234": "pnpm --filter @file-viewer/renderer-spreadsheet verify:github-234", "test:spreadsheet-github-405": "pnpm --filter @file-viewer/renderer-spreadsheet verify:github-405", "test:text-single-byte-encodings": "pnpm exec vitest run test/text-single-byte-encodings.spec.ts test/text-encoding-contracts.spec.ts && node packages/renderers/text/test/single-byte-encodings.browser.mjs", + "test:toolbar-zoom-github-398": "pnpm exec vitest run test/core-operations.spec.ts test/vue-toolbar-hook.spec.ts test/web-toolbar-order.spec.ts", "test:vite-copy-assets-github-257": "pnpm --filter @file-viewer/vite-plugin verify:github-257", "test:vue3-github-299": "pnpm exec vitest run test/vue3-error-event.spec.ts", "test:word-github-266": "pnpm --filter @file-viewer/core build && pnpm --filter @file-viewer/doc build && pnpm --filter @file-viewer/renderer-word build", "test:word-revisions-github-255": "pnpm exec vitest run test/word-revisions.spec.ts test/word-native-revisions.spec.ts test/vue3-render-options.spec.ts", "test:xml-github-305": "pnpm --filter @file-viewer/renderer-text build && pnpm exec vitest run test/core-options.spec.ts test/vue3-render-options.spec.ts && node packages/renderers/text/test/issue-305-xml-profiles.browser.mjs", "test:thumbnail": "pnpm --filter @file-viewer/thumbnail test", - "test": "pnpm verify:public-release-facts && pnpm test:i18n && pnpm test:thumbnail && pnpm test:archive-github-101 && pnpm test:archive-github-148 && pnpm test:bpmn-github-297 && pnpm test:cad-output-github-245 && pnpm test:doc-github-171 && pnpm test:doc-github-192 && pnpm test:doc-github-236 && pnpm test:doc-github-34 && pnpm test:doc-github-87 && pnpm test:email-github-232 && pnpm test:epub-github-292 && pnpm test:epub-github-300 && pnpm test:html-preview-github-256 && pnpm test:image-github-298 && pnpm test:ofd-github-177 && pnpm test:ofd-github-266 && pnpm test:ofd-github-94 && pnpm test:ofd-resources-github-57 && pnpm test:patch-github-304 && pnpm test:pdf-cjk-font-github-242 && pnpm test:pptx-assets-github-140 && pnpm test:pptx-github-176 && pnpm test:pptx-github-268 && pnpm test:reading-experience && pnpm test:requirejs-github-258 && pnpm test:shadow-focus-github-243 && pnpm test:spreadsheet-github-175 && pnpm test:spreadsheet-github-178 && pnpm test:spreadsheet-github-234 && pnpm test:spreadsheet-github-405 && pnpm test:text-single-byte-encodings && pnpm test:vite-copy-assets-github-257 && pnpm test:vue3-github-299 && pnpm test:word-github-266 && pnpm test:word-revisions-github-255 && pnpm test:xml-github-305 && pnpm test:ofd-signatures && pnpm test:ifc-entry", + "test": "pnpm verify:public-release-facts && pnpm test:i18n && pnpm test:thumbnail && pnpm test:archive-github-101 && pnpm test:archive-github-148 && pnpm test:bpmn-github-297 && pnpm test:cad-output-github-245 && pnpm test:doc-github-171 && pnpm test:doc-github-192 && pnpm test:doc-github-236 && pnpm test:doc-github-34 && pnpm test:doc-github-87 && pnpm test:email-github-232 && pnpm test:epub-github-292 && pnpm test:epub-github-300 && pnpm test:html-preview-github-256 && pnpm test:image-github-298 && pnpm test:ofd-github-177 && pnpm test:ofd-github-266 && pnpm test:ofd-github-94 && pnpm test:ofd-resources-github-57 && pnpm test:patch-github-304 && pnpm test:pdf-cjk-font-github-242 && pnpm test:pptx-assets-github-140 && pnpm test:pptx-github-176 && pnpm test:pptx-github-268 && pnpm test:reading-experience && pnpm test:requirejs-github-258 && pnpm test:shadow-focus-github-243 && pnpm test:spreadsheet-github-175 && pnpm test:spreadsheet-github-178 && pnpm test:spreadsheet-github-234 && pnpm test:spreadsheet-github-405 && pnpm test:text-single-byte-encodings && pnpm test:toolbar-zoom-github-398 && pnpm test:vite-copy-assets-github-257 && pnpm test:vue3-github-299 && pnpm test:word-github-266 && pnpm test:word-revisions-github-255 && pnpm test:xml-github-305 && pnpm test:ofd-signatures && pnpm test:ifc-entry", "type-check:components": "pnpm --filter @file-viewer/web type-check && pnpm --filter @file-viewer/react type-check && pnpm --filter @file-viewer/react-legacy type-check && pnpm --filter @file-viewer/vue3 type-check && pnpm --filter @file-viewer/vue2.7 type-check && pnpm --filter @file-viewer/vue2.6 type-check && pnpm --filter @file-viewer/jquery type-check && pnpm --filter @file-viewer/svelte type-check", "type-check:tools": "pnpm --filter './packages/tools/**' type-check", "docs:dev": "pnpm --filter @flyfish-group/file-viewer-docs dev", diff --git a/packages/build-support/licenses/asset-family-materials.json b/packages/build-support/licenses/asset-family-materials.json index 816d31da3..34e725f3b 100644 --- a/packages/build-support/licenses/asset-family-materials.json +++ b/packages/build-support/licenses/asset-family-materials.json @@ -444,8 +444,8 @@ }, { "sourcePath": "packages/renderers/dicom/THIRD_PARTY_NOTICES.md", - "sha256": "197379984adb7e753627d00a91364f7a9269c003822a057e6499c51b65fd78a6", - "bytes": 14376, + "sha256": "28e26035bf01dc15aae7e4ac3a5ea7c17f116a688fe9795ab9e92dda8db075cc", + "bytes": 14455, "kind": "generated-attribution-inventory", "component": "DICOM source inventory", "declaredLicense": null, diff --git a/packages/components/vue3/src/package/components/FileViewer/FileViewer.vue b/packages/components/vue3/src/package/components/FileViewer/FileViewer.vue index cc01eb715..29b9ff356 100644 --- a/packages/components/vue3/src/package/components/FileViewer/FileViewer.vue +++ b/packages/components/vue3/src/package/components/FileViewer/FileViewer.vue @@ -429,6 +429,7 @@ const { const { operationAvailability, + zoomButtons, visibleToolbar, toolbarOrder, showToolbar: showBuiltInToolbar, @@ -827,7 +828,7 @@ useViewerPreviewLifecycle({ :aria-label='viewerLabels.zoomGroup' >