perf: lazy-load section feature modules
This commit is contained in:
parent
06763a4602
commit
c43a11da68
@ -8,6 +8,9 @@ Follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) · versioned wi
|
|||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
- **Section-scoped feature loading.** The shared module manifest now builds a 17-module foundation bundle and loads
|
||||||
|
Rehearser, Reader/Audiobook, Library/Characters, and Studio code only when their sections are opened. Repeated
|
||||||
|
navigation reuses loaded modules, while deep links load their group before activation.
|
||||||
- **P1 Roadmap status.** Documented the completed namespace/manifest foundations and durable background artifacts,
|
- **P1 Roadmap status.** Documented the completed namespace/manifest foundations and durable background artifacts,
|
||||||
while keeping true lazy feature loading, responsibility-level frontend extraction, provider-health consolidation,
|
while keeping true lazy feature loading, responsibility-level frontend extraction, provider-health consolidation,
|
||||||
full workload measurements, and browser image-cache eviction explicitly open.
|
full workload measurements, and browser image-cache eviction explicitly open.
|
||||||
|
|||||||
16
TODO.md
16
TODO.md
@ -20,9 +20,9 @@ the first block before investing in architecture or polish.
|
|||||||
All P0 safety, security, data-integrity, and release-protection work is complete.
|
All P0 safety, security, data-integrity, and release-protection work is complete.
|
||||||
P1 implementation is active: pagination, shared voice caching, bounded decoded-audio
|
P1 implementation is active: pagination, shared voice caching, bounded decoded-audio
|
||||||
caching, background jobs, canonical character identity, forms, and the frontend
|
caching, background jobs, canonical character identity, forms, and the frontend
|
||||||
namespace/manifest foundations are working and regression-tested. Remaining P1 work
|
namespace/manifest foundations and section-scoped lazy loading are working and
|
||||||
is intentionally open where it needs a real architecture change or workload
|
regression-tested. Remaining P1 work is intentionally open where it needs a real
|
||||||
measurement: true feature-module lazy loading, the larger Audiobook/Rehearser/style
|
architecture change or workload measurement: the larger Audiobook/Rehearser/style
|
||||||
split, centralized provider-health handling, full constrained-device workloads, and
|
split, centralized provider-health handling, full constrained-device workloads, and
|
||||||
browser image/profile-cache eviction.
|
browser image/profile-cache eviction.
|
||||||
|
|
||||||
@ -89,9 +89,9 @@ browser image/profile-cache eviction.
|
|||||||
- [x] **L — Add character forms inside one character record.** `sheet.forms[]` stores bounded alternate appearances,
|
- [x] **L — Add character forms inside one character record.** `sheet.forms[]` stores bounded alternate appearances,
|
||||||
prompts, and per-form visual assets; the detail-page switcher preserves one character ID and voice, and selected
|
prompts, and per-form visual assets; the detail-page switcher preserves one character ID and voice, and selected
|
||||||
forms feed prompt generation and identity fingerprints.
|
forms feed prompt generation and identity fingerprints.
|
||||||
- [ ] **L — Modularize the frontend.** The shared manifest and compatibility namespace
|
- [ ] **L — Modularize the frontend.** The shared manifest, compatibility namespace
|
||||||
bridge are complete foundations; responsibility-level extraction, true feature-module
|
bridge, and section-scoped lazy loading are complete foundations; responsibility-level
|
||||||
lazy loading, and the Audiobook/Rehearser/style split remain.
|
extraction and the Audiobook/Rehearser/style split remain.
|
||||||
|
|
||||||
### P2 — after behavior and safety stabilize
|
### P2 — after behavior and safety stabilize
|
||||||
|
|
||||||
@ -132,7 +132,9 @@ browser image/profile-cache eviction.
|
|||||||
|
|
||||||
## P1 — Frontend performance and memory safety
|
## P1 — Frontend performance and memory safety
|
||||||
|
|
||||||
- [ ] Lazy-load Audiobook, Rehearser, Studio, and image-generation modules only when opened.
|
- [x] Lazy-load Audiobook, Rehearser, Studio, and image-generation modules only when opened. The shared manifest
|
||||||
|
keeps the foundation in the core bundle and loads Reader/Audiobook, Rehearser, Library/Characters, and Studio
|
||||||
|
groups once on first navigation; deep links load their group before activation.
|
||||||
- [x] Paginate large character and voice tables; render approximately 25–50 visible rows, with a constrained-device
|
- [x] Paginate large character and voice tables; render approximately 25–50 visible rows, with a constrained-device
|
||||||
regression covering a 250-character production.
|
regression covering a 250-character production.
|
||||||
- [x] Cache `/api/voices` with a shared 30-second session cache, one in-flight request, explicit refresh, and
|
- [x] Cache `/api/voices` with a shared 30-second session cache, one in-flight request, explicit refresh, and
|
||||||
|
|||||||
@ -7,7 +7,8 @@ import { join } from 'node:path';
|
|||||||
import { fileURLToPath } from 'node:url';
|
import { fileURLToPath } from 'node:url';
|
||||||
|
|
||||||
const root = join(fileURLToPath(new URL('.', import.meta.url)), '..');
|
const root = join(fileURLToPath(new URL('.', import.meta.url)), '..');
|
||||||
const modules = JSON.parse(readFileSync(join(root, 'static', 'module-manifest.json'), 'utf8'));
|
const manifest = JSON.parse(readFileSync(join(root, 'static', 'module-manifest.json'), 'utf8'));
|
||||||
|
const modules = [...new Set([...(manifest.core || []), ...Object.values(manifest.lazy || {}).flat()])];
|
||||||
const seenModules = new Set();
|
const seenModules = new Set();
|
||||||
const declarations = new Map();
|
const declarations = new Map();
|
||||||
const declarationPattern = /^(?:async\s+)?(?:function|class|const|let|var)\s+([A-Za-z_$][\w$]*)/gm;
|
const declarationPattern = /^(?:async\s+)?(?:function|class|const|let|var)\s+([A-Za-z_$][\w$]*)/gm;
|
||||||
|
|||||||
@ -18,8 +18,11 @@ const jsDir = join(root, 'static', 'js');
|
|||||||
const outDir = join(root, 'static', 'dist');
|
const outDir = join(root, 'static', 'dist');
|
||||||
mkdirSync(outDir, { recursive: true });
|
mkdirSync(outDir, { recursive: true });
|
||||||
|
|
||||||
// Must match loader.js batch C order exactly.
|
// Bundle only the foundation. Section-specific modules are loaded on demand by
|
||||||
const MODULES = JSON.parse(readFileSync(join(root, 'static', 'module-manifest.json'), 'utf8'));
|
// loader.js; keeping the same manifest as the runtime prevents drift.
|
||||||
|
const MANIFEST = JSON.parse(readFileSync(join(root, 'static', 'module-manifest.json'), 'utf8'));
|
||||||
|
const MODULES = MANIFEST.core;
|
||||||
|
if (!Array.isArray(MODULES) || !MODULES.length) throw new Error('module manifest has no core modules');
|
||||||
const MAIN = MODULES.map(n => join(jsDir, n + '.js'));
|
const MAIN = MODULES.map(n => join(jsDir, n + '.js'));
|
||||||
|
|
||||||
const source = MAIN.map(f => `\n/* ==== ${f.split('/').pop()} ==== */\n` + readFileSync(f, 'utf8')).join('\n');
|
const source = MAIN.map(f => `\n/* ==== ${f.split('/').pop()} ==== */\n` + readFileSync(f, 'utf8')).join('\n');
|
||||||
|
|||||||
967
static/dist/main.min.js
vendored
967
static/dist/main.min.js
vendored
File diff suppressed because one or more lines are too long
@ -127,24 +127,51 @@
|
|||||||
// C — feature modules: always try the bundle first (one request instead of 23).
|
// C — feature modules: always try the bundle first (one request instead of 23).
|
||||||
// Falls back to individual files if the bundle is missing or fails to load.
|
// Falls back to individual files if the bundle is missing or fails to load.
|
||||||
var _featureFiles = [];
|
var _featureFiles = [];
|
||||||
|
var _lazyFeatureGroups = {};
|
||||||
try {
|
try {
|
||||||
var _manifestResponse = await fetch('/static/module-manifest.json?v=' + _appVersion, { cache: 'no-store' });
|
var _manifestResponse = await fetch('/static/module-manifest.json?v=' + _appVersion, { cache: 'no-store' });
|
||||||
if (!_manifestResponse.ok) throw new Error('module manifest ' + _manifestResponse.status);
|
if (!_manifestResponse.ok) throw new Error('module manifest ' + _manifestResponse.status);
|
||||||
var _manifest = await _manifestResponse.json();
|
var _manifest = await _manifestResponse.json();
|
||||||
if (!Array.isArray(_manifest) || !_manifest.length) throw new Error('empty module manifest');
|
if (!Array.isArray(_manifest.core) || !_manifest.core.length) throw new Error('empty core module manifest');
|
||||||
_featureFiles = _manifest.map(function (name) { return '/static/js/' + name + '.js'; });
|
_featureFiles = _manifest.core.map(function (name) { return '/static/js/' + name + '.js'; });
|
||||||
|
_lazyFeatureGroups = _manifest.lazy && typeof _manifest.lazy === 'object' ? _manifest.lazy : {};
|
||||||
} catch (_) {
|
} catch (_) {
|
||||||
console.error('Unable to load the feature module manifest');
|
console.error('Unable to load the feature module manifest');
|
||||||
}
|
}
|
||||||
var _bundleOk = false;
|
var _bundleOk = false;
|
||||||
try { await _load('/static/dist/main.min.js'); _bundleOk = true; }
|
try { await _load('/static/dist/main.min.js'); _bundleOk = true; }
|
||||||
catch (_) { _bundleOk = false; }
|
catch (_) { _bundleOk = false; }
|
||||||
if (!_bundleOk) await _loadBatch(_featureFiles);
|
var _loadedFeatureModules = new Set();
|
||||||
|
if (_bundleOk) _featureFiles.forEach(function (src) { _loadedFeatureModules.add(src); });
|
||||||
|
if (!_bundleOk) {
|
||||||
|
await _loadBatch(_featureFiles);
|
||||||
|
_featureFiles.forEach(function (src) { _loadedFeatureModules.add(src); });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Public section loader used by nav.js. Modules are loaded in manifest order,
|
||||||
|
// exactly once, and a failed group leaves the existing section visible rather
|
||||||
|
// than turning navigation into a blank screen.
|
||||||
|
window.ensureFeatureModules = function (sectionId) {
|
||||||
|
var names = Array.isArray(_lazyFeatureGroups[sectionId]) ? _lazyFeatureGroups[sectionId] : [];
|
||||||
|
var pending = names.map(function (name) { return '/static/js/' + name + '.js'; })
|
||||||
|
.filter(function (src) { return !_loadedFeatureModules.has(src); });
|
||||||
|
if (!pending.length) return Promise.resolve();
|
||||||
|
pending.forEach(function (src) { _loadedFeatureModules.add(src); });
|
||||||
|
return _loadBatch(pending).catch(function (error) {
|
||||||
|
pending.forEach(function (src) { _loadedFeatureModules.delete(src); });
|
||||||
|
console.error('Unable to load section modules for ' + sectionId, error);
|
||||||
|
throw error;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
// D — navigation (must run before init/reveal so routing is interactive before
|
// D — navigation (must run before init/reveal so routing is interactive before
|
||||||
// async settings or voice-library refreshes can occupy the backend).
|
// async settings or voice-library refreshes can occupy the backend).
|
||||||
try { await _load('/static/nav.js'); } catch (_) {}
|
try { await _load('/static/nav.js'); } catch (_) {}
|
||||||
|
|
||||||
|
// A deep link or restored section must have its feature code before init and
|
||||||
|
// before the first visible section is activated.
|
||||||
|
try { if (window.ensureFeatureModules) await window.ensureFeatureModules(_activeSection); } catch (_) {}
|
||||||
|
|
||||||
// E — init (needs feature modules and nav globals to be defined)
|
// E — init (needs feature modules and nav globals to be defined)
|
||||||
try { await _load('/static/js/init.js'); } catch (_) {}
|
try { await _load('/static/js/init.js'); } catch (_) {}
|
||||||
|
|
||||||
|
|||||||
@ -1,6 +1,12 @@
|
|||||||
[
|
{
|
||||||
"voice-picker", "benchmark-voice-picker", "voice-inspector", "seed-finder", "voice-sources", "fishaudio-browser", "voice-cache", "jobs",
|
"core": [
|
||||||
"integrations", "routing", "voice-clone", "voice-library", "tts-preview", "generation", "benchmark", "stt",
|
"voice-picker", "benchmark-voice-picker", "voice-inspector", "seed-finder", "voice-sources", "fishaudio-browser", "voice-cache", "jobs",
|
||||||
"rehearser-parse", "rehearser", "reader", "audiobook", "audiobook/network", "character-sheets",
|
"integrations", "routing", "voice-clone", "voice-library", "tts-preview", "generation", "benchmark", "stt", "namespace"
|
||||||
"characters-library", "sillytavern", "library", "character-columns", "library-characters", "studio", "namespace"
|
],
|
||||||
]
|
"lazy": {
|
||||||
|
"s-rehearser": ["rehearser-parse", "rehearser"],
|
||||||
|
"s-reader": ["rehearser-parse", "rehearser", "characters-library", "reader", "audiobook/network", "audiobook", "character-sheets"],
|
||||||
|
"s-library": ["rehearser-parse", "rehearser", "characters-library", "reader", "audiobook/network", "audiobook", "character-sheets", "library", "character-columns", "library-characters", "sillytavern"],
|
||||||
|
"s-caststudio": ["rehearser-parse", "rehearser", "characters-library", "reader", "audiobook/network", "audiobook", "character-sheets", "library", "character-columns", "library-characters", "sillytavern", "studio"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -216,18 +216,24 @@
|
|||||||
// Preserve that early intent so startup cannot immediately switch back to
|
// Preserve that early intent so startup cannot immediately switch back to
|
||||||
// the previously stored section.
|
// the previously stored section.
|
||||||
if (SECTIONS.includes(sectionId)) window._navRequestedSection = sectionId;
|
if (SECTIONS.includes(sectionId)) window._navRequestedSection = sectionId;
|
||||||
showSection(sectionId);
|
var activate = function () {
|
||||||
var tabName = Object.keys(TAB_SECTION_MAP).find(function (k) {
|
showSection(sectionId);
|
||||||
return TAB_SECTION_MAP[k] === sectionId;
|
var tabName = Object.keys(TAB_SECTION_MAP).find(function (k) {
|
||||||
});
|
return TAB_SECTION_MAP[k] === sectionId;
|
||||||
if (tabName) runSideEffects(tabName);
|
});
|
||||||
// Keep the Reader/Casting/Characters/Rehearser breadcrumb in sync with
|
if (tabName) runSideEffects(tabName);
|
||||||
// plain sidebar navigation (not just the crumb's own buttons).
|
// Keep the Reader/Casting/Characters/Rehearser breadcrumb in sync with
|
||||||
if (typeof window.refreshWorkflowCrumbs === 'function') {
|
// plain sidebar navigation (not just the crumb's own buttons).
|
||||||
if (sectionId === 's-rehearser') window.refreshWorkflowCrumbs('rehearser');
|
if (typeof window.refreshWorkflowCrumbs === 'function') {
|
||||||
else if (sectionId === 's-reader') window.refreshWorkflowCrumbs('source');
|
if (sectionId === 's-rehearser') window.refreshWorkflowCrumbs('rehearser');
|
||||||
else if (sectionId === 's-library' && (window._libraryView || 'books') === 'characters') window.refreshWorkflowCrumbs('chars');
|
else if (sectionId === 's-reader') window.refreshWorkflowCrumbs('source');
|
||||||
|
else if (sectionId === 's-library' && (window._libraryView || 'books') === 'characters') window.refreshWorkflowCrumbs('chars');
|
||||||
|
}
|
||||||
|
};
|
||||||
|
if (typeof window.ensureFeatureModules === 'function') {
|
||||||
|
return window.ensureFeatureModules(sectionId).then(activate, function () { activate(); });
|
||||||
}
|
}
|
||||||
|
activate();
|
||||||
};
|
};
|
||||||
|
|
||||||
// Pulsing dot on the Read Aloud nav item while casting is in progress
|
// Pulsing dot on the Read Aloud nav item while casting is in progress
|
||||||
@ -246,9 +252,8 @@
|
|||||||
|
|
||||||
window.switchTab = function (name) {
|
window.switchTab = function (name) {
|
||||||
var sectionId = TAB_SECTION_MAP[name];
|
var sectionId = TAB_SECTION_MAP[name];
|
||||||
if (sectionId) showSection(sectionId);
|
if (!sectionId) return false;
|
||||||
runSideEffects(name);
|
return window.navTo(sectionId);
|
||||||
return true;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
// ── Voice tree sub-navigation ─────────────────────────────────────────────
|
// ── Voice tree sub-navigation ─────────────────────────────────────────────
|
||||||
@ -431,11 +436,16 @@
|
|||||||
window._settingsSidebarCat = localStorage.getItem('ttsvc_settings_cat') || 'connections';
|
window._settingsSidebarCat = localStorage.getItem('ttsvc_settings_cat') || 'connections';
|
||||||
window._enginesSidebarCat = localStorage.getItem('ttsvc_engines_cat') || 'llm';
|
window._enginesSidebarCat = localStorage.getItem('ttsvc_engines_cat') || 'llm';
|
||||||
var _startSection = window._navRequestedSection || hashSection() || ((_savedSection && SECTIONS.includes(_savedSection)) ? _savedSection : 's-voices');
|
var _startSection = window._navRequestedSection || hashSection() || ((_savedSection && SECTIONS.includes(_savedSection)) ? _savedSection : 's-voices');
|
||||||
showSection(_startSection);
|
if (typeof window.ensureFeatureModules === 'function') {
|
||||||
|
window.ensureFeatureModules(_startSection).then(function () { showSection(_startSection); }, function () { showSection(_startSection); });
|
||||||
|
} else showSection(_startSection);
|
||||||
|
|
||||||
window.addEventListener('hashchange', function () {
|
window.addEventListener('hashchange', function () {
|
||||||
var section = hashSection();
|
var section = hashSection();
|
||||||
if (section && section !== storedSection()) showSection(section);
|
if (section && section !== storedSection()) {
|
||||||
|
if (typeof window.ensureFeatureModules === 'function') window.ensureFeatureModules(section).then(function () { showSection(section); }, function () { showSection(section); });
|
||||||
|
else showSection(section);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
var _startTab = Object.keys(TAB_SECTION_MAP).find(function (k) { return TAB_SECTION_MAP[k] === _startSection; }) || 'library';
|
var _startTab = Object.keys(TAB_SECTION_MAP).find(function (k) { return TAB_SECTION_MAP[k] === _startSection; }) || 'library';
|
||||||
runSideEffects(_startTab);
|
runSideEffects(_startTab);
|
||||||
|
|||||||
@ -21,13 +21,13 @@ test('Characters / Cast supports profile sorting, column visibility, and voice r
|
|||||||
await page.route('**/api/voice/meta', route => route.fulfill({ json: { ok: true } }));
|
await page.route('**/api/voice/meta', route => route.fulfill({ json: { ok: true } }));
|
||||||
|
|
||||||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||||
await page.waitForFunction(() => typeof window.navTo === 'function' && typeof window.navLibraryView === 'function', null, { timeout: 15_000 });
|
await page.waitForFunction(() => typeof window.navTo === 'function', null, { timeout: 15_000 });
|
||||||
// The loader injects inactive section HTML in the background; wait until
|
// The loader injects inactive section HTML in the background; wait until
|
||||||
// the Library panel exists before switching its sub-view.
|
// the Library panel exists before switching its sub-view.
|
||||||
await page.waitForSelector('#lib-chars-list', { state: 'attached', timeout: 15_000 });
|
await page.waitForSelector('#lib-chars-list', { state: 'attached', timeout: 15_000 });
|
||||||
await page.waitForFunction(() => typeof window.libraryRenderCharacters === 'function', null, { timeout: 15_000 });
|
|
||||||
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
|
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
|
||||||
await page.evaluate(() => window.navTo('s-library'));
|
await page.evaluate(() => window.navTo('s-library'));
|
||||||
|
await page.waitForFunction(() => typeof window.navLibraryView === 'function' && typeof window.libraryRenderCharacters === 'function', null, { timeout: 15_000 });
|
||||||
await expect(page.locator('#s-library')).toHaveClass(/is-active/, { timeout: 15_000 });
|
await expect(page.locator('#s-library')).toHaveClass(/is-active/, { timeout: 15_000 });
|
||||||
await page.locator('.lib-tab[data-library-view="characters"]').evaluate(el => el.click());
|
await page.locator('.lib-tab[data-library-view="characters"]').evaluate(el => el.click());
|
||||||
await expect(page.locator('#s-library')).toHaveClass(/is-active/, { timeout: 15_000 });
|
await expect(page.locator('#s-library')).toHaveClass(/is-active/, { timeout: 15_000 });
|
||||||
@ -82,6 +82,8 @@ test('consistent image generation can be cancelled from its progress panel', asy
|
|||||||
}));
|
}));
|
||||||
|
|
||||||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.waitForFunction(() => typeof window.navTo === 'function', null, { timeout: 15_000 });
|
||||||
|
await page.evaluate(() => window.navTo('s-library'));
|
||||||
await page.waitForFunction(() => typeof window._charAutoGenerateConsistentImages === 'function', null, { timeout: 15_000 });
|
await page.waitForFunction(() => typeof window._charAutoGenerateConsistentImages === 'function', null, { timeout: 15_000 });
|
||||||
await page.evaluate(() => {
|
await page.evaluate(() => {
|
||||||
window.__cancelRun = window._charAutoGenerateConsistentImages({
|
window.__cancelRun = window._charAutoGenerateConsistentImages({
|
||||||
@ -110,8 +112,10 @@ test('current-version image pairs marked inconsistent show a review warning', as
|
|||||||
await page.route('**/api/settings*', route => route.fulfill({ json: {} }));
|
await page.route('**/api/settings*', route => route.fulfill({ json: {} }));
|
||||||
await page.route('**/api/book-profile*', route => route.fulfill({ json: { profile: {} } }));
|
await page.route('**/api/book-profile*', route => route.fulfill({ json: { profile: {} } }));
|
||||||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||||
await page.waitForFunction(() => typeof window.libraryRenderCharacters === 'function', null, { timeout: 15_000 });
|
|
||||||
await page.waitForSelector('#lib-chars-list', { state: 'attached', timeout: 15_000 });
|
await page.waitForSelector('#lib-chars-list', { state: 'attached', timeout: 15_000 });
|
||||||
|
await page.waitForFunction(() => typeof window.navTo === 'function', null, { timeout: 15_000 });
|
||||||
|
await page.evaluate(() => window.navTo('s-library'));
|
||||||
|
await page.waitForFunction(() => typeof window.libraryRenderCharacters === 'function', null, { timeout: 15_000 });
|
||||||
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
|
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
|
||||||
await page.locator('#nav-library-head').evaluate(el => el.click());
|
await page.locator('#nav-library-head').evaluate(el => el.click());
|
||||||
await page.locator('.lib-tab[data-library-view="characters"]').evaluate(el => el.click());
|
await page.locator('.lib-tab[data-library-view="characters"]').evaluate(el => el.click());
|
||||||
@ -122,8 +126,10 @@ test('current-version image pairs marked inconsistent show a review warning', as
|
|||||||
test('character appearance forms stay in one record and switch the detail view', async ({ page }) => {
|
test('character appearance forms stay in one record and switch the detail view', async ({ page }) => {
|
||||||
await page.route('**/api/book-profile*', route => route.fulfill({ json: { profile: {} } }));
|
await page.route('**/api/book-profile*', route => route.fulfill({ json: { profile: {} } }));
|
||||||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||||
await page.waitForFunction(() => typeof window._charDetailPage === 'function', null, { timeout: 15_000 });
|
|
||||||
await page.waitForSelector('#lib-chars-list', { state: 'attached', timeout: 15_000 });
|
await page.waitForSelector('#lib-chars-list', { state: 'attached', timeout: 15_000 });
|
||||||
|
await page.waitForFunction(() => typeof window.navTo === 'function', null, { timeout: 15_000 });
|
||||||
|
await page.evaluate(() => window.navTo('s-library'));
|
||||||
|
await page.waitForFunction(() => typeof window._charDetailPage === 'function', null, { timeout: 15_000 });
|
||||||
await page.evaluate(() => window._charDetailPage({
|
await page.evaluate(() => window._charDetailPage({
|
||||||
id: 'c-forms', book: 'Smoke Book', name: 'Zerwas', voice: 'de_bob',
|
id: 'c-forms', book: 'Smoke Book', name: 'Zerwas', voice: 'de_bob',
|
||||||
image: '/human.png', sheet: {
|
image: '/human.png', sheet: {
|
||||||
@ -144,6 +150,8 @@ test('character appearance forms stay in one record and switch the detail view',
|
|||||||
|
|
||||||
test('profile completeness counter distinguishes empty, partial, and full sheets', async ({ page }) => {
|
test('profile completeness counter distinguishes empty, partial, and full sheets', async ({ page }) => {
|
||||||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.waitForFunction(() => typeof window.navTo === 'function', null, { timeout: 15_000 });
|
||||||
|
await page.evaluate(() => window.navTo('s-library'));
|
||||||
await page.waitForFunction(() => typeof window._charProfileCompleteness === 'function', null, { timeout: 15_000 });
|
await page.waitForFunction(() => typeof window._charProfileCompleteness === 'function', null, { timeout: 15_000 });
|
||||||
const counts = await page.evaluate(() => ({
|
const counts = await page.evaluate(() => ({
|
||||||
empty: window._charProfileCompleteness({}),
|
empty: window._charProfileCompleteness({}),
|
||||||
|
|||||||
@ -30,8 +30,10 @@ test('large character productions keep the rendered DOM paginated', async ({ pag
|
|||||||
await page.route('**/api/voices*', route => route.fulfill({ json: [] }));
|
await page.route('**/api/voices*', route => route.fulfill({ json: [] }));
|
||||||
await page.route('**/api/settings*', route => route.fulfill({ json: {} }));
|
await page.route('**/api/settings*', route => route.fulfill({ json: {} }));
|
||||||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||||
await page.waitForFunction(() => typeof window.libraryRenderCharacters === 'function', null, { timeout: 15_000 });
|
|
||||||
await page.waitForSelector('#lib-chars-list', { state: 'attached', timeout: 15_000 });
|
await page.waitForSelector('#lib-chars-list', { state: 'attached', timeout: 15_000 });
|
||||||
|
await page.waitForFunction(() => typeof window.navTo === 'function', null, { timeout: 15_000 });
|
||||||
|
await page.evaluate(() => window.navTo('s-library'));
|
||||||
|
await page.waitForFunction(() => typeof window.libraryRenderCharacters === 'function', null, { timeout: 15_000 });
|
||||||
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
|
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
|
||||||
await page.evaluate(() => window.navTo('s-library'));
|
await page.evaluate(() => window.navTo('s-library'));
|
||||||
await page.locator('.lib-tab[data-library-view="characters"]').click();
|
await page.locator('.lib-tab[data-library-view="characters"]').click();
|
||||||
@ -88,6 +90,8 @@ test('voice library cache reuses fresh data and honors explicit refresh', async
|
|||||||
|
|
||||||
test('Rehearser audio cache stays bounded with LRU eviction', async ({ page }) => {
|
test('Rehearser audio cache stays bounded with LRU eviction', async ({ page }) => {
|
||||||
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.waitForFunction(() => typeof window.navTo === 'function', null, { timeout: 15_000 });
|
||||||
|
await page.evaluate(() => window.navTo('s-rehearser'));
|
||||||
await page.waitForFunction(() => window.rehState?.synthCache, null, { timeout: 15_000 });
|
await page.waitForFunction(() => window.rehState?.synthCache, null, { timeout: 15_000 });
|
||||||
const size = await page.evaluate(() => {
|
const size = await page.evaluate(() => {
|
||||||
const cache = window.rehState.synthCache;
|
const cache = window.rehState.synthCache;
|
||||||
|
|||||||
25
tests/lazy-loading.spec.js
Normal file
25
tests/lazy-loading.spec.js
Normal file
@ -0,0 +1,25 @@
|
|||||||
|
// @ts-check
|
||||||
|
// Section-specific feature code should not be fetched until its section is opened.
|
||||||
|
const { test, expect } = require('@playwright/test');
|
||||||
|
|
||||||
|
test('heavy feature modules load on demand', async ({ page }) => {
|
||||||
|
const requests = [];
|
||||||
|
page.on('request', request => {
|
||||||
|
if (request.url().includes('/static/js/')) requests.push(request.url());
|
||||||
|
});
|
||||||
|
|
||||||
|
await page.goto('/', { waitUntil: 'domcontentloaded' });
|
||||||
|
await page.waitForFunction(() => typeof window.navTo === 'function', null, { timeout: 15_000 });
|
||||||
|
await page.waitForSelector('#app-skeleton', { state: 'detached', timeout: 15_000 });
|
||||||
|
expect(requests.some(url => url.includes('/rehearser.js'))).toBeFalsy();
|
||||||
|
expect(requests.some(url => url.includes('/library-characters.js'))).toBeFalsy();
|
||||||
|
|
||||||
|
await page.evaluate(() => window.navTo('s-rehearser'));
|
||||||
|
await page.waitForFunction(() => window.rehState?.synthCache, null, { timeout: 15_000 });
|
||||||
|
expect(requests.some(url => url.includes('/rehearser.js'))).toBeTruthy();
|
||||||
|
|
||||||
|
const countBeforeRepeat = requests.filter(url => url.includes('/rehearser.js')).length;
|
||||||
|
await page.evaluate(() => window.navTo('s-rehearser'));
|
||||||
|
await page.waitForTimeout(100);
|
||||||
|
expect(requests.filter(url => url.includes('/rehearser.js')).length).toBe(countBeforeRepeat);
|
||||||
|
});
|
||||||
Loading…
Reference in New Issue
Block a user