Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1ddf864fb |
@@ -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)"
|
||||
|
||||
@@ -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
|
||||
@@ -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"
|
||||
@@ -200,3 +200,8 @@ cython_debug/
|
||||
|
||||
# PyPI configuration file
|
||||
.pypirc
|
||||
|
||||
# Maintainer-only JavaScript tooling and browser test artifacts
|
||||
node_modules/
|
||||
test-results/
|
||||
playwright-report/
|
||||
|
||||
@@ -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
@@ -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);
|
||||
});
|
||||
Vendored
+76
@@ -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
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+17
@@ -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"
|
||||
}
|
||||
}
|
||||
Vendored
+16
@@ -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 } },
|
||||
],
|
||||
});
|
||||
Vendored
+183
@@ -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);
|
||||
});
|
||||
Vendored
+8
@@ -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.
|
||||
Vendored
+34
@@ -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;
|
||||
}
|
||||
}
|
||||
Vendored
+70
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
Vendored
+3
-3
File diff suppressed because one or more lines are too long
Vendored
+3
-3
File diff suppressed because one or more lines are too long
Reference in New Issue
Block a user