Compare commits

...
Author SHA1 Message Date
Vito Sansevero e1ddf864fb feat: vendor Viewer.js reproducibly and automate release updates
Refs #169. Pin and verify pristine distribution assets, upgrade to 1.15.2, and add tested maintainer tooling plus desktop and touch browser checks.
2026-10-16 04:37:12 -07:00
18 changed files with 771 additions and 6 deletions
+12
View File
@@ -46,3 +46,15 @@ updates:
update-types:
- "minor"
- "patch"
# Maintainer browser test dependencies; Viewer.js itself has an asset-aware updater.
- package-ecosystem: "npm"
directory: "/tools/vendor"
schedule:
interval: "weekly"
allow:
- dependency-name: "@playwright/test"
labels:
- "dependencies"
commit-message:
prefix: "deps(vendor-tools)"
+41
View File
@@ -0,0 +1,41 @@
name: Viewer.js vendor and compatibility
on:
pull_request:
paths:
- 'tools/vendor/**'
- 'tests/vendor/**'
- 'tests/js/**'
- 'web/js/**'
- 'web/lib/**'
- '.github/workflows/viewerjs-*.yml'
push:
branches: [main]
paths:
- 'tools/vendor/**'
- 'tests/vendor/**'
- 'tests/js/**'
- 'web/js/**'
- 'web/lib/**'
- '.github/workflows/viewerjs-*.yml'
workflow_dispatch:
permissions:
contents: read
jobs:
viewerjs:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
- uses: actions/setup-node@v7
with:
node-version: '24'
cache: npm
cache-dependency-path: tools/vendor/package-lock.json
- run: npm ci --prefix tools/vendor --ignore-scripts
- run: npm run --prefix tools/vendor check
- run: npm run --prefix tools/vendor test
- run: node --test tests/js/*.test.js
- run: npx --prefix tools/vendor playwright install --with-deps chromium
- run: npm run --prefix tools/vendor test:browser
+83
View File
@@ -0,0 +1,83 @@
name: Update vendored Viewer.js
on:
schedule:
- cron: '30 7 * * *'
workflow_dispatch:
inputs:
version:
description: 'Stable version to vendor; leave blank for the latest npm release'
required: false
type: string
permissions:
contents: read
concurrency:
group: viewerjs-update
cancel-in-progress: false
jobs:
update:
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
actions: write
env:
GH_TOKEN: ${{ github.token }}
steps:
- uses: actions/checkout@v7
with:
ref: ${{ github.event.repository.default_branch }}
- uses: actions/setup-node@v7
with:
node-version: '24'
cache: npm
cache-dependency-path: tools/vendor/package-lock.json
- run: npm ci --prefix tools/vendor --ignore-scripts
- name: Update the dependency and regenerate assets
env:
VIEWER_VERSION: ${{ inputs.version }}
run: |
if [ -n "$VIEWER_VERSION" ]; then
npm run --prefix tools/vendor update -- --version "$VIEWER_VERSION"
else
npm run --prefix tools/vendor update
fi
- name: Detect changes
id: changes
run: |
if git diff --quiet -- tools/vendor/package.json tools/vendor/package-lock.json web/lib/viewerjs; then
echo 'changed=false' >> "$GITHUB_OUTPUT"
else
echo 'changed=true' >> "$GITHUB_OUTPUT"
fi
- if: steps.changes.outputs.changed == 'true'
run: |
npm run --prefix tools/vendor check
npm run --prefix tools/vendor test
node --test tests/js/*.test.js
npx --prefix tools/vendor playwright install --with-deps chromium
npm run --prefix tools/vendor test:browser
- name: Open the update PR
if: steps.changes.outputs.changed == 'true'
env:
BASE_BRANCH: ${{ github.event.repository.default_branch }}
run: |
version=$(node -p "require('./web/lib/viewerjs/provenance.json').version")
branch="deps/viewerjs-$version"
existing=$(gh pr list --repo "$GITHUB_REPOSITORY" --state all --head "$branch" --json number --jq 'length')
if [ "$existing" != '0' ]; then
echo "A PR already exists for Viewer.js $version; leaving it for review."
exit 0
fi
remote=$(git ls-remote origin "refs/heads/$branch")
if [ -z "$remote" ]; then
git switch -c "$branch"
git add tools/vendor/package.json tools/vendor/package-lock.json web/lib/viewerjs
git -c user.name='github-actions[bot]' -c user.email='41898282+github-actions[bot]@users.noreply.github.com' commit -m "deps: update vendored Viewer.js to $version"
git push origin "HEAD:refs/heads/$branch"
fi
gh pr create --repo "$GITHUB_REPOSITORY" --base "$BASE_BRANCH" --head "$branch" --title "deps: update vendored Viewer.js to $version" --body-file tools/vendor/update-pr.md
gh workflow run viewerjs-check.yml --repo "$GITHUB_REPOSITORY" --ref "$branch"
+5
View File
@@ -200,3 +200,8 @@ cython_debug/
# PyPI configuration file
.pypirc
# Maintainer-only JavaScript tooling and browser test artifacts
node_modules/
test-results/
playwright-report/
+3
View File
@@ -139,6 +139,9 @@ The comprehensive gallery system provides complete management of all ComfyUI out
_Powered by [ViewerJS](https://github.com/fengyuanchen/viewerjs) for professional image viewing capabilities._
Viewer.js is vendored locally. See [Viewer.js maintenance](tools/vendor/README.md)
for reproducible updates, verification, and automated update PRs.
### 🔬 ComfyUI Workflow Analysis
- **PNG Metadata Parsing**: Advanced PNG chunk analysis to extract embedded ComfyUI data
+107
View File
@@ -0,0 +1,107 @@
import test from 'node:test';
import assert from 'node:assert/strict';
import { mkdtempSync, mkdirSync, writeFileSync, readFileSync, rmSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { createHash } from 'node:crypto';
import { syncViewer } from '../../tools/vendor/vendor-viewerjs.mjs';
import { updateViewer } from '../../tools/vendor/update-viewerjs.mjs';
function fixture(t) {
const root = mkdtempSync(join(tmpdir(), 'pm-vendor-'));
t.after(() => rmSync(root, { recursive: true, force: true }));
const tooling = join(root, 'tools/vendor');
const dependency = join(tooling, 'node_modules/viewerjs');
mkdirSync(join(dependency, 'dist'), { recursive: true });
writeFileSync(join(tooling, 'package.json'), JSON.stringify({ devDependencies: { viewerjs: '1.15.2' } }));
writeFileSync(join(tooling, 'package-lock.json'), JSON.stringify({ packages: {
'node_modules/viewerjs': { version: '1.15.2', resolved: 'https://registry.npmjs.org/viewerjs/-/viewerjs-1.15.2.tgz', integrity: 'sha512-fixture' },
} }));
writeFileSync(join(dependency, 'package.json'), JSON.stringify({ name: 'viewerjs', version: '1.15.2', license: 'MIT' }));
for (const name of ['viewer.min.js', 'viewer.min.css']) {
writeFileSync(join(dependency, 'dist', name), `/*! Viewer.js v1.15.2 */\n${name}`);
}
writeFileSync(join(dependency, 'LICENSE'), 'The MIT License (MIT)\nCopyright Chen Fengyuan\n');
return { root, tooling, dependency, destination: join(root, 'web/lib/viewerjs') };
}
test('sync copies pristine assets and license and records locked provenance deterministically', t => {
const f = fixture(t);
syncViewer({ root: f.root });
const first = readFileSync(join(f.destination, 'provenance.json'), 'utf8');
const record = JSON.parse(first);
assert.equal(record.version, '1.15.2');
assert.equal(record.integrity, 'sha512-fixture');
for (const [target, source] of Object.entries({ 'viewer.min.js': 'dist/viewer.min.js', 'viewer.min.css': 'dist/viewer.min.css', LICENSE: 'LICENSE' })) {
const original = readFileSync(join(f.dependency, source));
assert.deepEqual(readFileSync(join(f.destination, target)), original);
assert.equal(record.files[target].sha256, createHash('sha256').update(original).digest('hex'));
}
syncViewer({ root: f.root });
assert.equal(readFileSync(join(f.destination, 'provenance.json'), 'utf8'), first);
syncViewer({ root: f.root, check: true });
});
test('check rejects drift without overwriting local changes', t => {
const f = fixture(t);
syncViewer({ root: f.root });
writeFileSync(join(f.destination, 'viewer.min.js'), 'local edit');
assert.throws(() => syncViewer({ root: f.root, check: true }), /viewer.min.js/);
assert.equal(readFileSync(join(f.destination, 'viewer.min.js'), 'utf8'), 'local edit');
rmSync(join(f.destination, 'LICENSE'));
assert.throws(() => syncViewer({ root: f.root, check: true }), /LICENSE/);
});
test('sync refuses mismatched installed versions and unpinned dependencies', t => {
const f = fixture(t);
writeFileSync(join(f.dependency, 'package.json'), JSON.stringify({ name: 'viewerjs', version: '1.11.6', license: 'MIT' }));
assert.throws(() => syncViewer({ root: f.root }), /version/i);
writeFileSync(join(f.tooling, 'package.json'), JSON.stringify({ devDependencies: { viewerjs: '^1.15.2' } }));
assert.throws(() => syncViewer({ root: f.root }), /exact|pin/i);
});
test('sync validates every upstream asset before writing anything', t => {
const f = fixture(t);
writeFileSync(join(f.dependency, 'dist/viewer.min.css'), '/*! Viewer.js v1.11.6 */');
assert.throws(() => syncViewer({ root: f.root }), /version|banner/i);
assert.throws(() => readFileSync(join(f.destination, 'viewer.min.js')), /ENOENT/);
});
test('updater resolves the stable release and installs it exactly with lifecycle scripts disabled', t => {
const f = fixture(t);
const calls = [];
let synced = false;
const run = (args, options) => {
calls.push({ args, options });
return args[0] === 'view' ? '"1.15.3"' : '';
};
assert.equal(updateViewer({ root: f.root, run, sync: () => { synced = true; } }), '1.15.3');
assert.deepEqual(calls[1].args, ['install', '--save-dev', '--save-exact', '--ignore-scripts', 'viewerjs@1.15.3']);
assert.equal(calls[1].options.cwd, f.tooling);
assert.equal(synced, true);
});
test('updater is a no-op at the current release and rejects invalid version arguments', t => {
const f = fixture(t);
const run = () => { throw new Error('must not install'); };
assert.equal(updateViewer({ root: f.root, version: '1.15.2', run }), null);
for (const version of ['latest', '1.15.3-beta.1', '1.15.3;anything', '--global']) {
assert.throws(() => updateViewer({ root: f.root, version, run }), /version/i);
}
});
test('failed installation never copies vendor assets', t => {
const f = fixture(t);
let synced = false;
assert.throws(() => updateViewer({ root: f.root, version: '1.15.3', run: () => { throw new Error('offline'); }, sync: () => { synced = true; } }), /offline/);
assert.equal(synced, false);
});
test('updater accepts a single registry result array and rejects ambiguous metadata', t => {
const f = fixture(t);
const calls = [];
const run = args => { calls.push(args); return '["1.15.2"]'; };
assert.equal(updateViewer({ root: f.root, run }), null);
assert.equal(calls.length, 1);
assert.throws(() => updateViewer({ root: f.root, run: () => '["1.15.2", "1.15.3"]' }), /version/i);
});
+76
View File
@@ -0,0 +1,76 @@
# Viewer.js maintenance
ComfyUI serves the committed files in `web/lib/viewerjs/`. Node and npm are only
needed by maintainers and CI. Keep upstream files pristine; application behavior
belongs in `web/js/`, and theme overrides belong in `comfyui-theme.css`.
## Install and verify
Use Node 24 or newer. From the repository root:
```sh
npm ci --prefix tools/vendor --ignore-scripts
npm run --prefix tools/vendor check
npm run --prefix tools/vendor test
node --test tests/js/*.test.js
npx --prefix tools/vendor playwright install chromium
npm run --prefix tools/vendor test:browser
```
On Linux, use `playwright install --with-deps chromium` when browser system
libraries are missing. The browser tests use synthetic images and mock the
ComfyUI API boundary; they run production viewer methods and the committed
Viewer.js JS/CSS, without a running ComfyUI instance.
`check` is read-only and fails if any JS, CSS, license, or provenance file differs
from the installed package. The manifest, lockfile, installed package, and asset
version banners must agree. The lockfile records npm's archive integrity;
`provenance.json` records the upstream source, package integrity, and SHA-256 of
each copied file. A normal `npm ci --ignore-scripts` verifies archive integrity.
## Update
```sh
npm run --prefix tools/vendor update
# Or select a specific stable version:
npm run --prefix tools/vendor update -- --version 1.15.2
```
The command installs an exact version with lifecycle scripts disabled and copies
`dist/viewer.min.js`, `dist/viewer.min.css`, and `LICENSE`. It writes deterministic
provenance with no timestamps. Repeat the checks above and commit the manifest,
lockfile, and all vendor files together. To regenerate files from the current
pin without changing versions, run `npm run --prefix tools/vendor sync`.
## Automated update PRs
`Update vendored Viewer.js` checks npm's stable release daily at 07:30 UTC and
supports **Actions → Update vendored Viewer.js → Run workflow**, with an optional
version. It tests the generated update before opening `deps/viewerjs-X.Y.Z`.
Existing open or closed PRs for that release are left alone. A failed test does
not publish a branch or PR. Changes require review; the workflow never merges
or approves PRs.
The organization and repository must enable **Actions → General → Workflow
permissions → Allow GitHub Actions to create and approve pull requests**.
Keep default workflow permissions read-only. Only the update job requests
`contents: write`, `pull-requests: write`, and `actions: write`.
The workflow uses `GITHUB_TOKEN`; no personal token is needed. Because bot-token
pushes do not normally trigger other workflows, it explicitly dispatches the
read-only `Viewer.js vendor and compatibility` workflow on the update branch.
Both workflow files must be merged into the default branch before scheduling or
manual dispatch becomes available.
## Compatibility checklist
Check gallery grid/list views, navigation across 100-image windows, metadata,
slideshow/fullscreen, keyboard navigation, Escape and focus cleanup. Check the
admin gallery/filmstrip and tag viewer. On touch devices, test long swipes,
short drags, and pinch/rotate gestures. Confirm the ComfyUI theme remains usable.
The gallery keeps its bounded viewer and custom slideshow. Upstream navbar
windowing is not a replacement for bounding the supplied image list: native
slideshow playback can still preload every supplied original. Review new
preloading defaults, gesture behavior, CSS changes, and any adapter use of
Viewer.js internal methods before adopting another release.
+68
View File
@@ -0,0 +1,68 @@
{
"name": "promptmanager-vendor-tools",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "promptmanager-vendor-tools",
"version": "1.0.0",
"devDependencies": {
"@playwright/test": "1.64.1",
"viewerjs": "1.15.2"
}
},
"node_modules/@playwright/test": {
"version": "1.64.1",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.1.tgz",
"integrity": "sha512-IaYGml/nSOiZWROfPtuIL7neuu9qDOZ3TNvNljUIrhDMxiGTgPjzj/g9p9RxpS2tSepPDRY+UwAaEeiKtUvIAg==",
"dev": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.64.1"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"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/viewerjs": {
"version": "1.15.2",
"resolved": "https://registry.npmjs.org/viewerjs/-/viewerjs-1.15.2.tgz",
"integrity": "sha512-CYEqSFfPUSnmn1PjrhYxXOScaUC7n8+NpSqI+MrOSM8wcKPe35MlwQYwksuD5rjFkYv/ePmY5NsUa51uRNN2jA==",
"dev": true,
"license": "MIT"
}
}
}
+17
View File
@@ -0,0 +1,17 @@
{
"name": "promptmanager-vendor-tools",
"private": true,
"version": "1.0.0",
"description": "Maintainer-only tools for reproducible browser dependencies",
"scripts": {
"sync": "node vendor-viewerjs.mjs",
"check": "node vendor-viewerjs.mjs --check",
"update": "node update-viewerjs.mjs",
"test": "node --test ../../tests/vendor/*.test.mjs",
"test:browser": "playwright test --config playwright.config.mjs"
},
"devDependencies": {
"@playwright/test": "1.64.1",
"viewerjs": "1.15.2"
}
}
+16
View File
@@ -0,0 +1,16 @@
import { defineConfig } from '@playwright/test';
export default defineConfig({
testDir: './tests',
testMatch: '*.spec.mjs',
fullyParallel: true,
workers: 2,
forbidOnly: Boolean(process.env.CI),
retries: process.env.CI ? 1 : 0,
reporter: 'list',
use: { browserName: 'chromium', headless: true, trace: 'retain-on-failure' },
projects: [
{ name: 'desktop', use: { viewport: { width: 1280, height: 800 } } },
{ name: 'touch', use: { viewport: { width: 800, height: 700 }, hasTouch: true, isMobile: true } },
],
});
+183
View File
@@ -0,0 +1,183 @@
import { test, expect } from '@playwright/test';
import { readFileSync } from 'node:fs';
import { join } from 'node:path';
import { repositoryRoot as root } from '../vendor-viewerjs.mjs';
const source = file => readFileSync(join(root, file), 'utf8');
async function waitForImage(page, index) {
await expect.poll(() => page.evaluate(() => window.gallery.viewer?.viewed)).toBe(true);
await expect.poll(() => page.evaluate(() => window.gallery.lastMetadata)).toContain(`/original/${index}.svg`);
}
async function loadGallery(page) {
await page.addScriptTag({ content: source('web/js/gallery.js') + '\nwindow.TestGallery = GalleryManager;' });
await page.evaluate(() => {
const manager = window.gallery = Object.create(TestGallery.prototype);
Object.assign(manager, {
viewer: null, viewMode: 'grid',
images: Array.from({ length: 2000 }, (_, i) => ({ filename: `${i}.svg`,
url: `http://viewer.test/original/${i}.svg`, thumbnail_url: `http://viewer.test/thumb/${i}.svg`,
relative_path: `${i}.svg`, size: 100, modified_time: 1 })),
getSettings: () => ({ showImageInfo: true }),
loadImageMetadata(url, content) { this.lastMetadata = url; content.textContent = url; },
});
manager.renderGallery();
});
}
test.beforeEach(async ({ page }) => {
page.viewerErrors = [];
page.on('pageerror', error => page.viewerErrors.push(error.message));
await page.route('http://viewer.test/**', route => route.request().resourceType() === 'document'
? route.fulfill({ contentType: 'text/html', body: '<head><meta name="viewport" content="width=device-width, initial-scale=1"></head><body><button id="outside">Outside</button><div id="galleryGrid"></div><div id="galleryList"></div><div id="loadingState"></div><div id="errorState"></div><div id="galleryContent"></div><div id="galleryEmpty"></div><h1 id="galleryTitle"></h1></body>' })
: route.fulfill({ contentType: 'image/svg+xml', body: '<svg xmlns="http://www.w3.org/2000/svg" width="128" height="128"><rect width="128" height="128" fill="teal"/></svg>' }));
await page.goto('http://viewer.test/');
await page.addStyleTag({ path: join(root, 'web/lib/tailwind/comfyui-theme.css') });
await page.addStyleTag({ path: join(root, 'web/lib/tailwind/styles.css') });
await page.addStyleTag({ path: join(root, 'web/lib/viewerjs/viewer.min.css') });
await page.addStyleTag({ content: '.hidden{display:none} #galleryGrid{display:grid;grid-template-columns:repeat(8,1fr)} #galleryGrid img{width:100px;height:100px}' });
for (const file of ['web/lib/viewerjs/viewer.min.js', 'web/js/html-escape.js', 'web/js/image-helpers.js', 'web/js/api-client.js']) {
await page.addScriptTag({ path: join(root, file) });
}
});
test.afterEach(async ({ page }) => {
expect(page.viewerErrors).toEqual([]);
});
test('gallery retains cards and navigates across bounded windows with correct metadata', async ({ page }) => {
await loadGallery(page);
await page.locator('[data-image-index="1500"]').click();
await waitForImage(page, 1500);
expect(await page.evaluate(() => gallery.viewer.length)).toBeLessThanOrEqual(100);
expect(await page.evaluate(() => {
const first = document.querySelector('.image-item');
gallery.images.push({ ...gallery.images[0], filename: 'appended.svg' });
gallery.renderGallery({ from: 2000 });
return first === document.querySelector('.image-item');
})).toBe(true);
await page.evaluate(() => gallery.viewer.view(99));
await waitForImage(page, 1549);
await page.keyboard.press('ArrowRight');
await waitForImage(page, 1550);
await page.keyboard.press('ArrowLeft');
await waitForImage(page, 1549);
await page.keyboard.press('Escape');
await expect.poll(() => page.evaluate(() => gallery.viewer.isShown)).toBe(false);
await expect(page.locator('#metadata-sidebar')).toHaveCount(0);
await page.locator('#outside').focus();
await expect(page.locator('#outside')).toBeFocused();
});
test('gallery slideshow advances without preloading the collection and cleans up on close', async ({ page }) => {
await loadGallery(page);
await page.evaluate(() => gallery.openViewer(1500));
await waitForImage(page, 1500);
await page.clock.install();
await page.locator('[data-viewer-action="play"]').click();
expect(await page.evaluate(() => gallery.slideshowPlaying)).toBe(true);
await expect(page.locator('.viewer-player img')).toHaveCount(0);
await page.clock.fastForward(5000);
await waitForImage(page, 1501);
await page.locator('[data-viewer-action="play"]').click();
expect(await page.evaluate(() => gallery.slideshowPlaying)).toBe(false);
await page.keyboard.press('Escape');
await expect.poll(() => page.evaluate(() => gallery.viewer.isShown)).toBe(false);
await page.evaluate(() => gallery.destroyViewer());
await expect(page.locator('.viewer-container')).toHaveCount(0);
});
test('admin gallery and filmstrip use the real viewer events and destroy temporary containers', async ({ page }) => {
// Exercise the production class without starting the ComfyUI dashboard bootstrap.
const adminSource = source('web/js/admin.js');
await page.addScriptTag({ content: adminSource.slice(0, adminSource.indexOf(' function startAdmin()')) + '\nwindow.TestAdmin = PromptAdmin;' });
await page.evaluate(async () => {
const admin = window.admin = Object.create(TestAdmin.prototype);
const images = Array.from({ length: 3 }, (_, i) => ({ filename: `${i}.svg`, url: `http://viewer.test/original/${i}.svg`, size: 100, created_at: 1 }));
Object.assign(admin, {
prompts: [{ id: 1, text: 'test' }],
getImageUrl: image => image.url,
loadFilmStripImages: async () => images,
addMetadataSidebar() {},
loadMetadataForImage(image) { this.lastMetadata = image.src; },
});
await admin.openFilmStripViewer(1, 1);
});
await expect.poll(() => page.evaluate(() => admin.lastMetadata)).toContain('/1.svg');
await page.keyboard.press('ArrowRight');
await expect.poll(() => page.evaluate(() => admin.lastMetadata)).toContain('/2.svg');
await page.keyboard.press('Escape');
await expect(page.locator('#filmStripViewerContainer')).toHaveCount(0);
await expect(page.locator('.viewer-container')).toHaveCount(0);
await page.evaluate(() => admin.renderGallery([{ filename: 'a.svg', url: 'http://viewer.test/original/0.svg', size: 100, created_at: 1 }], 1));
await expect.poll(() => page.evaluate(() => Boolean(admin.galleryViewer))).toBe(true);
await page.evaluate(() => admin.galleryViewer.show());
await expect.poll(() => page.evaluate(() => admin.galleryViewer.viewed)).toBe(true);
await page.keyboard.press('Escape');
await expect.poll(() => page.evaluate(() => admin.galleryViewer.isShown)).toBe(false);
await page.evaluate(() => admin.galleryViewer.destroy());
});
test('tag viewer opens at the selected image and cleans up on Escape', async ({ page }) => {
await page.addScriptTag({ path: join(root, 'web/js/tags-page.js') });
await page.evaluate(async () => {
const tags = Object.create(TagsPageManager.prototype);
tags.admin = { getImageUrl: image => image.url };
await tags.openImageViewer({ images: [0, 1, 2].map(i => ({ filename: `${i}.svg`, url: `http://viewer.test/original/${i}.svg` })), image_count: 3 }, 1);
});
await expect(page.locator('.viewer-canvas img')).toHaveAttribute('src', /\/1.svg$/);
await page.keyboard.press('ArrowRight');
await expect(page.locator('.viewer-canvas img')).toHaveAttribute('src', /\/2.svg$/);
await page.keyboard.press('Escape');
await expect(page.locator('.viewer-container')).toHaveCount(0);
});
test('touch swipes navigate the gallery and restore short drags', async ({ page, isMobile }) => {
test.skip(!isMobile, 'Touch interaction project');
await loadGallery(page);
await page.evaluate(() => gallery.openViewer(1500));
await waitForImage(page, 1500);
await page.locator('.metadata-collapse-btn').click();
await expect(page.locator('.metadata-sidebar')).toHaveClass(/collapsed/);
const session = await page.context().newCDPSession(page);
const width = await page.evaluate(() => gallery.viewer.viewerData.width);
const swipe = async distance => {
await session.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: [{ x: width * 0.9, y: 220 }] });
for (let step = 1; step <= 8; step++) {
await session.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: [{ x: width * 0.9 - distance * step / 8, y: 220 }] });
}
await session.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
};
await swipe(width * 0.8);
await waitForImage(page, 1501);
const initialX = await page.evaluate(() => gallery.viewer.imageData.x);
await swipe(width * 0.1);
await waitForImage(page, 1501);
await expect.poll(() => page.evaluate(() => gallery.viewer.imageData.x)).toBe(initialX);
});
test('touch pinch zoom and rotation remain usable', async ({ page, isMobile }) => {
test.skip(!isMobile, 'Touch interaction project');
await loadGallery(page);
await page.evaluate(() => gallery.openViewer(1500));
await waitForImage(page, 1500);
await page.locator('.metadata-collapse-btn').click();
await expect(page.locator('.metadata-sidebar')).toHaveClass(/collapsed/);
const session = await page.context().newCDPSession(page);
const points = (radius, angle = 0) => [-1, 1].map((sign, id) => ({
id, x: 400 + sign * radius * Math.cos(angle), y: 220 + sign * radius * Math.sin(angle),
}));
await session.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: points(30) });
for (let step = 1; step <= 6; step++) {
await session.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: points(30 + step * 8) });
}
await session.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
await expect.poll(() => page.evaluate(() => gallery.viewer.imageData.ratio > 1.2)).toBe(true);
await session.send('Input.dispatchTouchEvent', { type: 'touchStart', touchPoints: points(70) });
for (let step = 1; step <= 8; step++) {
await session.send('Input.dispatchTouchEvent', { type: 'touchMove', touchPoints: points(70, Math.PI / 2 * step / 8) });
}
await session.send('Input.dispatchTouchEvent', { type: 'touchEnd', touchPoints: [] });
await expect.poll(() => page.evaluate(() => Math.abs(gallery.viewer.imageData.rotate))).toBe(90);
});
+8
View File
@@ -0,0 +1,8 @@
Update the exact Viewer.js dependency pin and lockfile, pristine JavaScript and CSS, MIT license, and provenance hashes together.
Validation completed before this PR was opened:
- Vendored assets match the installed locked package.
- Vendoring tooling and existing frontend tests pass.
- Chromium desktop and touch compatibility checks pass for the gallery, admin, and tag viewers.
Review the [upstream releases](https://github.com/fengyuanchen/viewerjs/releases) for behavioral changes. This PR requires review and is not automatically merged.
+34
View File
@@ -0,0 +1,34 @@
import { execFileSync } from 'node:child_process';
import { readFileSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { pathToFileURL } from 'node:url';
import { repositoryRoot, stableVersion, syncViewer } from './vendor-viewerjs.mjs';
function npm(args, options) {
if (!process.env.npm_execpath) throw new Error('Run the updater through npm run update');
return execFileSync(process.execPath, [process.env.npm_execpath, ...args], options);
}
export function updateViewer({ root = repositoryRoot, version, run = npm, sync = syncViewer } = {}) {
const cwd = join(root, 'tools/vendor');
const current = JSON.parse(readFileSync(join(cwd, 'package.json'), 'utf8')).devDependencies.viewerjs;
const result = version ?? JSON.parse(run(['view', 'viewerjs', 'version', '--json'], { cwd, encoding: 'utf8' }));
const target = Array.isArray(result) && result.length === 1 ? result[0] : result;
if (typeof target !== 'string' || !stableVersion.test(target)) throw new Error('Expected a stable Viewer.js version such as 1.15.2');
if (target === current) return null;
run(['install', '--save-dev', '--save-exact', '--ignore-scripts', `viewerjs@${target}`], { cwd, stdio: 'inherit' });
sync({ root });
return target;
}
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
try {
const args = process.argv.slice(2);
if (args.length && (args.length !== 2 || args[0] !== '--version')) throw new Error('Usage: npm run update -- [--version X.Y.Z]');
const version = updateViewer({ version: args[1] });
console.log(version ? `Updated Viewer.js to ${version}` : 'Viewer.js is already current');
} catch (error) {
console.error(error.message);
process.exitCode = 1;
}
}
+70
View File
@@ -0,0 +1,70 @@
import { createHash } from 'node:crypto';
import { readFileSync, writeFileSync, mkdirSync } from 'node:fs';
import { resolve, join } from 'node:path';
import { fileURLToPath, pathToFileURL } from 'node:url';
export const repositoryRoot = fileURLToPath(new URL('../..', import.meta.url));
export const stableVersion = /^\d+\.\d+\.\d+$/;
const readJson = path => JSON.parse(readFileSync(path, 'utf8'));
export function syncViewer({ root = repositoryRoot, check = false } = {}) {
const tooling = join(root, 'tools/vendor');
const manifest = readJson(join(tooling, 'package.json'));
const version = manifest.devDependencies.viewerjs;
if (!stableVersion.test(version)) throw new Error('Viewer.js must use an exact stable version pin');
const locked = readJson(join(tooling, 'package-lock.json')).packages['node_modules/viewerjs'];
const installed = join(tooling, 'node_modules/viewerjs');
const info = readJson(join(installed, 'package.json'));
if (locked?.version !== version || info.version !== version) {
throw new Error('Viewer.js version mismatch: run npm ci --ignore-scripts in tools/vendor');
}
if (!locked.integrity || !locked.resolved || info.name !== 'viewerjs' || info.license !== 'MIT') {
throw new Error('Viewer.js package identity, integrity, or license is missing');
}
const files = {};
const output = new Map();
for (const [target, source] of Object.entries({
'viewer.min.js': 'dist/viewer.min.js',
'viewer.min.css': 'dist/viewer.min.css',
LICENSE: 'LICENSE',
})) {
const content = readFileSync(join(installed, source));
if (target !== 'LICENSE' && !content.toString('utf8').includes(`Viewer.js v${version}\n`) &&
!content.toString('utf8').includes(`Viewer.js v${version} */`)) {
throw new Error(`Viewer.js version banner mismatch in ${source}`);
}
files[target] = { source, sha256: createHash('sha256').update(content).digest('hex') };
output.set(target, content);
}
output.set('provenance.json', Buffer.from(JSON.stringify({
name: info.name,
version,
source: `https://github.com/fengyuanchen/viewerjs/tree/v${version}`,
resolved: locked.resolved,
integrity: locked.integrity,
files,
}, null, 2) + '\n'));
const destination = join(root, 'web/lib/viewerjs');
if (check) {
const drift = [...output].filter(([name, content]) => {
try { return !readFileSync(join(destination, name)).equals(content); }
catch (error) { if (error.code === 'ENOENT') return true; throw error; }
}).map(([name]) => name);
if (drift.length) throw new Error(`Vendored Viewer.js drift: ${drift.join(', ')}. Run npm run sync in tools/vendor.`);
} else {
mkdirSync(destination, { recursive: true });
for (const [name, content] of output) writeFileSync(join(destination, name), content);
}
return version;
}
if (process.argv[1] && import.meta.url === pathToFileURL(resolve(process.argv[1])).href) {
try {
const args = process.argv.slice(2);
if (args.some(arg => arg !== '--check')) throw new Error('Usage: vendor-viewerjs.mjs [--check]');
console.log(`Viewer.js ${syncViewer({ check: args.includes('--check') })}: ${args.includes('--check') ? 'verified' : 'synced'}`);
} catch (error) {
console.error(error.message);
process.exitCode = 1;
}
}
+21
View File
@@ -0,0 +1,21 @@
The MIT License (MIT)
Copyright 2015-present Chen Fengyuan
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in
all copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN
THE SOFTWARE.
+21
View File
@@ -0,0 +1,21 @@
{
"name": "viewerjs",
"version": "1.15.2",
"source": "https://github.com/fengyuanchen/viewerjs/tree/v1.15.2",
"resolved": "https://registry.npmjs.org/viewerjs/-/viewerjs-1.15.2.tgz",
"integrity": "sha512-CYEqSFfPUSnmn1PjrhYxXOScaUC7n8+NpSqI+MrOSM8wcKPe35MlwQYwksuD5rjFkYv/ePmY5NsUa51uRNN2jA==",
"files": {
"viewer.min.js": {
"source": "dist/viewer.min.js",
"sha256": "490580be4988dff2e05cd9c81c4161b82ce0c3715d268fadf8f7193853f40031"
},
"viewer.min.css": {
"source": "dist/viewer.min.css",
"sha256": "24a43eb0b188c824bb5b32e4d95c08b6162ec01eaa709ca8f0823d9512d0913d"
},
"LICENSE": {
"source": "LICENSE",
"sha256": "eb917650781dcf06a60b97cba9b05bf583931ec2d311d8a5548a8dca90a57e0f"
}
}
}
+3 -3
View File
File diff suppressed because one or more lines are too long
+3 -3
View File
File diff suppressed because one or more lines are too long