* feat(system): run thumbnail generation as an Activity job
GET /prompt_manager/images/thumbnails/plan counts what a run would do
(targets, fresh, remaining past the cap, source labels) and writes
nothing. POST /prompt_manager/jobs/thumbnails {mode, size, quality}
starts the "thumbnails" job: admission guarded, cancellable at a file
boundary, outcome groups generated / skipped / errors. size is the
longest edge (128-2048), quality the encoder, and rebuild writes fresh
previews again through the same atomic rename, so a failed file keeps
its old preview and originals are only read.
The legacy gallery stream now starts or attaches to the same job and
streams its unchanged frames. A client that goes away no longer stops
the work; the gallery's Cancel asks the server to cancel the job.
* feat(activity): per-kind job descriptors
One descriptor table (activity-kinds.mjs) decides each job kind's pill
verb, toasts, line, tiles, outcomes, openable rows and labels; the
store re-exports it. The scan and image tagging views are unchanged.
A thumbnail job counts Generated, Skipped and Errors, reads
"Generating thumbnails" in the pill and "Thumbnails finished · N
generated · M errors" when it ends.
Any other kind (the health check, a held legacy-page operation) gets
the generic descriptor with no tiles and no Results, instead of the
scan's zero tiles. A running job without a known total shows an
indeterminate progress bar and no percentage, and only a job the
server can cancel shows Cancel.
* feat(system): review and start thumbnail jobs
Sources' Thumbnail generation -> Review options and Overview's Review
thumbnails open the concept's Thumbnail maintenance dialog: scope, quality
(preselected from Image loading quality) and preview size limit, a plan
line from the read-only thumbnail plan (D99) and Start, which starts the
thumbnails Activity job and opens it. A size outside 128-2048 requests
nothing; a refused start shows the busy link toast.
* fix(system): thumbnail job review fixes (#249 S5)
- The legacy stream's first frame is job {id, started}, sent after the
subscription so no runner frame is missed; it reads the registry after
prepare, so a job started in that gap is attached to, not refused.
- Every exit sets a truthful final status line; nothing to do never
reads "Processing file 0 of 0".
- Cancel is checked per file during the planning walk; a job cancelled
there publishes no start frame.
- The ffmpeg fallback keeps the chosen quality (-q:v) and the aspect
ratio; the placeholder uses the chosen JPEG settings.
- A locked preview on Windows reads "Preview in use by another program;
the old one was kept", not the raw WinError.
- Thumbnail work no longer defers the monitor's Needs review recording:
it writes previews only, never records.
- The disconnect test now proves the job outlives its stream (one worker,
9 files, three batches); the attach test waits on an event.
* fix(gallery): cancel the stream's own thumbnail job by id
The settings panel and the startup dialog cancel the job named by the
stream's job frame, and only one their stream started. A Cancel before
the frame waits for it, a double Cancel posts once, and a job another
tab or System started keeps running. The startup dialog now settles on
Cancel instead of staying on "Cancelling...".
* fix(activity): indeterminate pill bar and the nothing-to-do line
The pill's mini bar sweeps while a job's total is unknown instead of
showing an empty fill. A finished thumbnail job with nothing to do shows
the server's final line. Coverage floors raised for gallery.js and
activity.mjs.
233 lines
11 KiB
JavaScript
233 lines
11 KiB
JavaScript
const test = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fs = require('node:fs');
|
|
const path = require('node:path');
|
|
const vm = require('node:vm');
|
|
|
|
function fixture(status, settings = {}) {
|
|
const timers = [];
|
|
const prompts = [];
|
|
const requests = [];
|
|
const buttons = {};
|
|
const storage = new Map();
|
|
const document = {
|
|
addEventListener() {},
|
|
createElement: () => ({ addEventListener() {} }),
|
|
body: { appendChild() {}, removeChild() {} },
|
|
getElementById: id => buttons[id] ||= { addEventListener(type, handler) { this[type] = handler; } },
|
|
};
|
|
const context = vm.createContext({ document, window: {}, console: { debug() {}, warn() {} },
|
|
setTimeout: callback => timers.push(callback),
|
|
localStorage: { setItem: (key, value) => storage.set(key, value) },
|
|
ApiClient: { fetchUrl: async () => ({ json: async () => ({ success: true,
|
|
total: 10000, images: Array.from({ length: 50 }, () => ({ thumbnail_url: null })),
|
|
}) }) },
|
|
});
|
|
const Manager = vm.runInContext(fs.readFileSync(path.join(__dirname, '../../web/js/gallery.js'), 'utf8') + '\nGalleryManager;', context, { filename: path.join(__dirname, '../../web/js/gallery.js') });
|
|
const manager = Object.create(Manager.prototype);
|
|
Object.assign(manager, { getSettings: () => ({ checkThumbnailsAtStartup: true, ...settings }),
|
|
api: { get: async url => { requests.push(url); return status; } },
|
|
});
|
|
const originalPrompt = manager.showThumbnailGenerationPrompt.bind(manager);
|
|
manager.showThumbnailGenerationPrompt = (...args) => prompts.push(args);
|
|
async function check() {
|
|
await manager.checkThumbnailsAtStartup();
|
|
await timers.shift()();
|
|
}
|
|
return { manager, check, prompts, requests, settings, buttons, storage, originalPrompt };
|
|
}
|
|
|
|
test('startup uses exact collection counts instead of extrapolating the newest 50 images', async () => {
|
|
const f = fixture({ success: true, total_images: 10000, pending: 50, ready: 9950, collection_id: 'a' });
|
|
await f.check();
|
|
assert.deepEqual(f.requests, ['/prompt_manager/images/thumbnails/status']);
|
|
assert.equal(f.prompts.length, 0);
|
|
});
|
|
|
|
test('startup prompt reports the actual number needing generation', async () => {
|
|
const f = fixture({ success: true, total_images: 10000, pending: 1200, collection_id: 'a' });
|
|
await f.check();
|
|
assert.equal(f.prompts.length, 1);
|
|
assert.deepEqual(f.prompts[0].slice(0, 2), [10000, 1200]);
|
|
});
|
|
|
|
test('a complete collection never prompts again', async () => {
|
|
const f = fixture({ success: true, total_images: 10000, pending: 0, collection_id: 'a' });
|
|
await f.check();
|
|
await f.check();
|
|
assert.equal(f.prompts.length, 0);
|
|
});
|
|
|
|
test('Not Now persists the collection state and suppresses the unchanged prompt', async () => {
|
|
const status = { success: true, total_images: 100, pending: 30, collection_id: 'a' };
|
|
const f = fixture(status);
|
|
await f.check();
|
|
f.originalPrompt(...f.prompts[0]);
|
|
f.buttons.thumbnailPromptNo.click();
|
|
Object.assign(f.settings, JSON.parse(f.storage.get('gallerySettings')));
|
|
f.prompts.length = 0;
|
|
await f.check();
|
|
assert.equal(f.prompts.length, 0);
|
|
status.pending = 40;
|
|
await f.check();
|
|
assert.equal(f.prompts.length, 1);
|
|
});
|
|
|
|
// WebKit fails a fetch a leaving page starts ("Fetch API cannot load … due to access control checks.") and logs it
|
|
// as a page error: the delayed startup check starts no read once the page has begun to leave (as admin.js, #290).
|
|
test('the startup check starts no read once the page is leaving', async () => {
|
|
const f = fixture({ success: true, total_images: 100, pending: 30, collection_id: 'a' });
|
|
f.manager.pageLifetime = new AbortController();
|
|
f.manager.pageLifetime.abort();
|
|
await f.check();
|
|
assert.equal(f.requests.length, 0);
|
|
assert.equal(f.prompts.length, 0);
|
|
});
|
|
|
|
test('the startup check passes the page lifetime to its read', async () => {
|
|
const f = fixture({ success: true, total_images: 100, pending: 30, collection_id: 'a' });
|
|
const signals = [];
|
|
f.manager.pageLifetime = new AbortController();
|
|
const get = f.manager.api.get;
|
|
f.manager.api.get = async (url, options) => { signals.push(options?.signal); return get(url); };
|
|
await f.check();
|
|
assert.deepEqual(signals, [f.manager.pageLifetime.signal]);
|
|
assert.equal(f.prompts.length, 1);
|
|
});
|
|
|
|
test('disabled checks do not request collection status', async () => {
|
|
const f = fixture({}, { checkThumbnailsAtStartup: false });
|
|
await f.check();
|
|
assert.equal(f.requests.length, 0);
|
|
assert.equal(f.prompts.length, 0);
|
|
});
|
|
|
|
test('saving gallery settings retains the remembered thumbnail dismissal', () => {
|
|
const f = fixture({}, { thumbnailPromptDismissed: 'remembered-state' });
|
|
f.manager.showNotification = () => {};
|
|
f.manager.hideSettings = () => {};
|
|
f.manager.applySettings = () => {};
|
|
f.manager.saveSettings();
|
|
assert.equal(JSON.parse(f.storage.get('gallerySettings')).thumbnailPromptDismissed, 'remembered-state');
|
|
});
|
|
|
|
// #249 System S5: the stream's work is now an Activity job that outlives the page's connection, so the gallery's
|
|
// Cancel asks the server to stop the job. The stream's first frame, `job {id, started}`, names the job; the gallery
|
|
// cancels by that id, only a job this stream started, and a Cancel before the id waits for it (review I1).
|
|
const JOB = 'b'.repeat(32);
|
|
|
|
function streamFixture({ modal = false, postFails = false } = {}) {
|
|
const posts = [];
|
|
const warnings = [];
|
|
const gets = [];
|
|
const listeners = {};
|
|
const source = { closed: 0, readyState: 1, CLOSED: 2, url: null,
|
|
addEventListener(type, handler) { (listeners[type] ||= []).push(handler); },
|
|
close() { this.closed += 1; } };
|
|
const element = () => ({ classList: { add() {}, remove() {} }, style: {}, textContent: '', handlers: {},
|
|
addEventListener(type, handler) { this.handlers[type] = handler; },
|
|
removeEventListener(type, handler) { if (this.handlers[type] === handler) delete this.handlers[type]; },
|
|
click() { this.handlers.click?.(); } });
|
|
const elements = {};
|
|
const document = { addEventListener() {}, getElementById: id => elements[id] ||= element(),
|
|
createElement: () => element(), body: { appendChild() {}, removeChild() {} } };
|
|
const context = vm.createContext({ document, window: {},
|
|
console: { debug() {}, warn: (...args) => warnings.push(args), error() {} },
|
|
setTimeout() {}, localStorage: { setItem() {} },
|
|
SseStream: { connect: (url, options) => { source.url = url; source.options = options; return source; } } });
|
|
const file = path.join(__dirname, '../../web/js/gallery.js');
|
|
const Manager = vm.runInContext(fs.readFileSync(file, 'utf8') + '\nGalleryManager;', context, { filename: file });
|
|
const manager = Object.create(Manager.prototype);
|
|
Object.assign(manager, { getSettings: () => ({}),
|
|
api: { get: async url => { gets.push(url); return { success: true, jobs: [] }; },
|
|
post: async (url, body) => {
|
|
posts.push([url, body]);
|
|
if (postFails) throw new Error('offline');
|
|
return { success: true };
|
|
} } });
|
|
const emit = (type, data) => (listeners[type] || []).forEach(handler => handler({ data: JSON.stringify(data) }));
|
|
const ui = { addStatusMessage() {}, progressText: element(), progressPercent: element(), progressBar: element(),
|
|
progressDetails: element(), progressETA: element(), isCancelled: () => false,
|
|
onCancel: handler => { ui.cancel = handler; } };
|
|
const result = modal ? manager.generateThumbnailsWithProgressInModal(ui)
|
|
: manager.generateThumbnailsWithProgress({ quality: 'medium' });
|
|
const outcome = result.then(value => ({ value }), error => ({ error: error.message }));
|
|
const cancel = () => (modal ? ui.cancel() : elements.cancelThumbnailsBtn.click());
|
|
const settle = () => new Promise(resolve => setImmediate(resolve));
|
|
return { emit, source, posts, gets, warnings, outcome, cancel, settle };
|
|
}
|
|
|
|
const cancelUrl = id => `/prompt_manager/jobs/${id}/cancel`;
|
|
|
|
test('Cancel before the job id arrives cancels that job once it does', async () => {
|
|
const f = streamFixture();
|
|
f.cancel();
|
|
await f.settle();
|
|
assert.deepEqual(f.posts, []);
|
|
assert.equal(f.source.closed, 0); // kept open until the stream names its job
|
|
assert.deepEqual(await f.outcome, { error: 'Thumbnail generation cancelled by user' });
|
|
f.emit('status', { phase: 'scanning', message: 'Scanning…' });
|
|
f.emit('job', { id: JOB, started: true });
|
|
await f.settle();
|
|
assert.equal(JSON.stringify(f.posts), JSON.stringify([[cancelUrl(JOB), {}]]));
|
|
assert.equal(f.source.closed, 1);
|
|
f.emit('job', { id: 'c'.repeat(32), started: true }); // a stray second frame changes nothing
|
|
await f.settle();
|
|
assert.equal(f.posts.length, 1);
|
|
assert.deepEqual(f.gets, []); // never looks a job up by kind
|
|
});
|
|
|
|
test('Cancel after the id cancels it at once, and a double Cancel posts once', async () => {
|
|
const f = streamFixture();
|
|
f.emit('job', { id: JOB, started: true });
|
|
f.cancel();
|
|
f.cancel();
|
|
await f.settle();
|
|
assert.equal(JSON.stringify(f.posts), JSON.stringify([[cancelUrl(JOB), {}]]));
|
|
assert.equal(f.source.closed, 1);
|
|
assert.deepEqual(await f.outcome, { error: 'Thumbnail generation cancelled by user' });
|
|
assert.deepEqual(f.gets, []);
|
|
});
|
|
|
|
test("Cancel leaves another tab's or System's job running and only stops following it", async () => {
|
|
const f = streamFixture();
|
|
f.emit('job', { id: JOB, started: false });
|
|
f.cancel();
|
|
await f.settle();
|
|
assert.deepEqual(f.posts, []);
|
|
assert.deepEqual(f.gets, []);
|
|
assert.equal(f.source.closed, 1);
|
|
assert.match((await f.outcome).error, /keeps running/);
|
|
});
|
|
|
|
test('a Cancel the stream never names a job for posts nothing and closes when it ends', async () => {
|
|
const f = streamFixture();
|
|
f.cancel();
|
|
f.emit('error', { error: 'Scan for metadata already running' });
|
|
await f.settle();
|
|
assert.deepEqual(f.posts, []);
|
|
assert.equal(f.source.closed, 1);
|
|
});
|
|
|
|
test('a cancel post that fails is reported, not thrown', async () => {
|
|
const f = streamFixture({ postFails: true });
|
|
f.emit('job', { id: JOB, started: true });
|
|
f.cancel();
|
|
await f.settle();
|
|
assert.equal(f.posts.length, 1);
|
|
assert.equal(f.warnings.length, 1);
|
|
assert.deepEqual(await f.outcome, { error: 'Thumbnail generation cancelled by user' });
|
|
});
|
|
|
|
test('the startup dialog cancels its own job by id too', async () => {
|
|
const f = streamFixture({ modal: true });
|
|
f.cancel();
|
|
await f.settle();
|
|
assert.deepEqual(f.posts, []);
|
|
f.emit('job', { id: JOB, started: true });
|
|
await f.settle();
|
|
assert.equal(JSON.stringify(f.posts), JSON.stringify([[cancelUrl(JOB), {}]]));
|
|
assert.equal(f.source.closed, 1);
|
|
assert.match((await f.outcome).error, /cancelled/);
|
|
});
|