* docs(ui): prototype unified prompt management application * docs(ui): add masonry and per-image tagging to application concept * docs(ui): make gallery concept use continuous infinite scrolling * feat(ui): introduce the tested application workspace shell * fix(docs): preserve concept controls across cached navigation * test(ci): emit Python tracebacks for native test crashes * test(ui): cover workspace restoration across cached navigation
143 lines
7.1 KiB
JavaScript
143 lines
7.1 KiB
JavaScript
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
import { createScope } from '../../web/js/shared/toolkit/core.js';
|
|
import { mountWorkspaceTheme } from '../../web/js/workspace/theme.mjs';
|
|
|
|
function fixture({ dark = false, preference, fetchImpl, storageDenied = false, pathname = '/prompt_manager/workspace' } = {}) {
|
|
const media = new EventTarget(); media.matches = dark;
|
|
const win = new EventTarget(); win.location = { pathname }; win.matchMedia = () => media;
|
|
const documentElement = { style: { colorScheme: 'light dark' } };
|
|
const root = { dataset: {}, ownerDocument: { documentElement } };
|
|
const saved = new Map(preference === undefined ? [] : [['pm_workspace_theme', preference]]);
|
|
const storage = {
|
|
getItem(key) { if (storageDenied) throw new Error('denied'); return saved.get(key); },
|
|
setItem(key, value) { if (storageDenied) throw new Error('denied'); saved.set(key, value); },
|
|
};
|
|
const calls = [];
|
|
const scope = createScope();
|
|
const theme = mountWorkspaceTheme({ root, scope, win, storage, fetchImpl: fetchImpl || (async (url, init) => {
|
|
calls.push({ url, init }); return { ok: true, json: async () => 'dark' };
|
|
}) });
|
|
return { root, documentElement, saved, media, win, scope, theme, calls };
|
|
}
|
|
const response = value => ({ ok: true, json: async () => value });
|
|
const tick = () => new Promise(resolve => setTimeout(resolve, 0));
|
|
|
|
test('follow uses host palette over OS and preserves reverse proxy prefix', async () => {
|
|
const f = fixture({ pathname: '/proxy/comfy/prompt_manager/workspace' });
|
|
await f.theme.ready;
|
|
assert.equal(f.root.dataset.caTheme, 'dark');
|
|
assert.equal(f.documentElement.style.colorScheme, 'dark');
|
|
assert.equal(f.calls[0].url, '/proxy/comfy/api/settings/Comfy.ColorPalette');
|
|
f.scope.dispose();
|
|
});
|
|
|
|
test('supported light palettes accept string and wrapped values', async () => {
|
|
for (const palette of ['light', 'solarized', 'github', 'arc']) {
|
|
const f = fixture({ dark: true, fetchImpl: async () => response({ value: palette }) });
|
|
await f.theme.ready;
|
|
assert.equal(f.root.dataset.caTheme, 'light', palette);
|
|
f.scope.dispose();
|
|
}
|
|
});
|
|
|
|
test('explicit overrides persist and follow restores host choice', async () => {
|
|
const f = fixture({ preference: 'light' }); await f.theme.ready;
|
|
assert.equal(f.root.dataset.caTheme, 'light');
|
|
f.theme.setPreference('dark'); assert.equal(f.root.dataset.caTheme, 'dark');
|
|
assert.equal(f.saved.get('pm_workspace_theme'), 'dark');
|
|
f.theme.setPreference('follow'); assert.equal(f.root.dataset.caTheme, 'dark');
|
|
assert.equal(f.saved.get('pm_workspace_theme'), 'follow');
|
|
assert.throws(() => f.theme.setPreference('orange'), /preference/i);
|
|
f.scope.dispose();
|
|
});
|
|
|
|
test('unknown host palettes and invalid stored preferences follow changing OS', async () => {
|
|
const f = fixture({ preference: 'orange', fetchImpl: async () => response('custom') });
|
|
await f.theme.ready; assert.equal(f.root.dataset.caTheme, 'light');
|
|
f.media.matches = true; f.media.dispatchEvent(new Event('change'));
|
|
assert.equal(f.root.dataset.caTheme, 'dark'); f.scope.dispose();
|
|
});
|
|
|
|
test('network, HTTP, JSON and storage failures recover using system theme', async () => {
|
|
for (const fetchImpl of [async () => { throw new Error('offline'); }, async () => ({ ok: false }), async () => ({ ok: true, json: async () => { throw new Error('invalid'); } })]) {
|
|
const f = fixture({ dark: true, storageDenied: true, fetchImpl });
|
|
await f.theme.ready; assert.equal(f.root.dataset.caTheme, 'dark');
|
|
assert.doesNotThrow(() => f.theme.setPreference('light'));
|
|
assert.equal(f.root.dataset.caTheme, 'light'); f.scope.dispose();
|
|
}
|
|
});
|
|
|
|
test('focus refreshes host palette and stale responses cannot replace newest palette', async () => {
|
|
const pending = [];
|
|
const f = fixture({ fetchImpl: (url, init) => new Promise(resolve => pending.push({ resolve, signal: init.signal })) });
|
|
f.win.dispatchEvent(new Event('focus'));
|
|
assert.equal(pending.length, 2); assert.equal(pending[0].signal.aborted, true);
|
|
pending[1].resolve(response('light')); await tick();
|
|
pending[0].resolve(response('dark')); await f.theme.ready;
|
|
assert.equal(f.root.dataset.caTheme, 'light'); f.scope.dispose();
|
|
});
|
|
|
|
test('cleanup aborts requests, restores attributes and native scheme and removes listeners', async () => {
|
|
let resolve, fSignal;
|
|
const f = fixture({ fetchImpl: (url, init) => new Promise(done => { resolve = done; fSignal = init.signal; }) });
|
|
f.scope.dispose(); assert.equal(fSignal.aborted, true);
|
|
assert.equal(f.root.dataset.caTheme, undefined);
|
|
assert.equal(f.documentElement.style.colorScheme, 'light dark');
|
|
f.win.dispatchEvent(new Event('focus')); f.media.dispatchEvent(new Event('change'));
|
|
resolve(response('dark')); await f.theme.ready;
|
|
assert.equal(f.root.dataset.caTheme, undefined);
|
|
f.theme.setPreference('dark'); assert.equal(f.root.dataset.caTheme, undefined);
|
|
});
|
|
|
|
test('cleanup restores an existing toolkit theme', async () => {
|
|
const f = fixture(); await f.theme.ready; f.scope.dispose();
|
|
f.root.dataset.caTheme = 'light';
|
|
const scope = createScope();
|
|
const theme = mountWorkspaceTheme({ root: f.root, scope, win: f.win, storage: f.saved, fetchImpl: async () => response('dark') });
|
|
await theme.ready; scope.dispose(); assert.equal(f.root.dataset.caTheme, 'light');
|
|
});
|
|
|
|
|
|
test('malformed palette payloads fall back without losing native theme alignment', async () => {
|
|
for (const payload of [null, {}, {value: 42}, 42]) {
|
|
const f = fixture({ dark: true, fetchImpl: async () => response(payload) });
|
|
await f.theme.ready;
|
|
assert.equal(f.root.dataset.caTheme, 'dark');
|
|
assert.equal(f.documentElement.style.colorScheme, 'dark');
|
|
f.scope.dispose();
|
|
}
|
|
});
|
|
|
|
test('focus failure clears obsolete host palette and resumes system following', async () => {
|
|
let calls = 0;
|
|
const f = fixture({ fetchImpl: async () => {
|
|
if (++calls === 1) return response('dark');
|
|
throw new Error('host offline');
|
|
} });
|
|
await f.theme.ready;
|
|
assert.equal(f.root.dataset.caTheme, 'dark');
|
|
f.win.dispatchEvent(new Event('focus')); await tick();
|
|
assert.equal(f.root.dataset.caTheme, 'light');
|
|
f.scope.dispose();
|
|
f.win.dispatchEvent(new Event('focus')); await tick();
|
|
assert.equal(calls, 2);
|
|
});
|
|
|
|
test('blocked localStorage getter and missing proxy marker stay usable', async () => {
|
|
const f = fixture(); await f.theme.ready; f.scope.dispose();
|
|
Object.defineProperty(f.win, 'localStorage', { get() { throw new Error('denied'); } });
|
|
f.win.location.pathname = '/standalone';
|
|
const scope = createScope();
|
|
let url;
|
|
const theme = mountWorkspaceTheme({ root: f.root, win: f.win, scope,
|
|
fetchImpl: async value => { url = value; return response(' Obsidian '); } });
|
|
assert.equal(theme.preference, 'follow');
|
|
await theme.ready;
|
|
assert.equal(url, '/api/settings/Comfy.ColorPalette');
|
|
assert.equal(f.root.dataset.caTheme, 'dark');
|
|
theme.setPreference('light');
|
|
assert.equal(theme.preference, 'light');
|
|
scope.dispose();
|
|
});
|