From cd284432f5adca353284b30a91906506740e8626 Mon Sep 17 00:00:00 2001 From: Vito Date: Fri, 30 Oct 2026 22:06:12 +0000 Subject: [PATCH 01/17] feat(cutover): every entry point opens the workspace; legacy pages stay reachable (#528) * feat(cutover): every entry point opens the workspace; legacy pages stay reachable The top-bar PM button, the nodes' Web UI buttons and /prompt_manager/web now open the workspace Library. Overview's cards open their native pages. The legacy pages stay at their URLs, with no route changed. Classic Web UI no longer navigates the canvas away when a popup is blocked; it shows a notification, as the focused node does. Closes #525 * feat(cutover): link the legacy pages from System and when the workspace fails to load System's Overview section ends with one small Legacy pages line (D165). If a workspace module fails to load or throws while starting, the page shows a static message with the same links instead of an empty shell. Part of #525 * test(cutover): prove the Pair node's Web UI on the real host Part of #525 * test(cutover): count the new real-host cases in the stock matrix pin The PM button test and three renderer-and-reload tests, in 2 projects, take the stock matrix from 110 to 118. Part of #525 --- README.md | 13 +- TESTING.md | 32 ++++- tests/browser/classic-node.spec.mjs | 24 +++- tests/browser/focused-node.spec.mjs | 2 +- tests/browser/system-concept.spec.mjs | 4 +- tests/browser/workspace.spec.mjs | 120 ++++++++++++++++--- tests/comfyui/focused-node.spec.mjs | 6 +- tests/comfyui/prompt-manager.spec.mjs | 66 +++++++++- tests/comfyui/workspace.spec.mjs | 6 +- tests/js/workspace-entry.test.js | 34 ++++++ tests/js/workspace-state.test.js | 28 ++++- tests/vendor/comfyui-config.test.mjs | 3 +- tools/vendor/frontend-coverage-baseline.json | 6 + web/comfy/prompt_manager.js | 19 +-- web/index.html | 10 +- web/js/focused-node.js | 3 +- web/js/workspace-entry.js | 2 + web/js/workspace/index.mjs | 11 +- web/js/workspace/state.mjs | 36 +++--- web/js/workspace/system.mjs | 21 +++- web/js/workspace/workspace.css | 1 + web/workspace.html | 14 +++ 22 files changed, 379 insertions(+), 82 deletions(-) create mode 100644 tests/js/workspace-entry.test.js create mode 100644 web/js/workspace-entry.js diff --git a/README.md b/README.md index 946967d1..66d96f8b 100644 --- a/README.md +++ b/README.md @@ -251,9 +251,9 @@ match Classic and Focused. - **PromptManagerText**: Look for "Prompt Manager Text" in the PromptManager/Text category - outputs STRING for any text node 5. **Access the web interface**: - - **Admin Dashboard**: `http://localhost:8188/prompt_manager/admin` - Full management interface - - **Metadata Viewer**: `http://localhost:8188/prompt_manager/metadata.html` - Standalone PNG analysis tool - - **Simple Interface**: `http://localhost:8188/prompt_manager/` - Basic prompt browser + - **Workspace**: click **PM** in ComfyUI's top bar or **Web UI** on a node, or open `http://localhost:8188/prompt_manager/workspace` + - **Legacy pages** still work at their URLs, and System's Overview section links to them: + `/prompt_manager/admin` (dashboard), `/prompt_manager/admin#/tags` (tags), `/prompt_manager/gallery` (gallery), `/prompt_manager/gallery.html` (metadata viewer) ### Prerequisites @@ -324,9 +324,10 @@ The comprehensive gallery system provides complete access to all your ComfyUI ou #### **📁 Accessing the Gallery** -1. **Open the admin interface** at `http://localhost:8188/prompt_manager/admin` -2. **Click the "🖼️ Gallery" button** to open the output-wide gallery -3. **Browse all content** from your ComfyUI output directory +1. **Open the workspace's Images page** at `http://localhost:8188/prompt_manager/workspace#/images` +2. **Browse all content** from your ComfyUI output directory + +The legacy gallery stays at `http://localhost:8188/prompt_manager/gallery`. #### **🎬 Video & Image Support** diff --git a/TESTING.md b/TESTING.md index 22ed40bd..b67bf09c 100644 --- a/TESTING.md +++ b/TESTING.md @@ -2641,8 +2641,8 @@ applied), D35 (singular captions), D36 (sorted categories), D40 (the continuatio the grid) and D43 (sorted chips, checked against the concept with its chips sorted). D44 needs no mapping: the comparison runs with no Refine filter active, so the button reads "Filters" as the concept's does. Nothing else follows the grid: the legacy -"Current prompt library" link is removed from the shelf; Overview's legacy card ("Open -existing prompt library") and the sidebar still link `/prompt_manager/admin` +"Current prompt library" link is removed from the shelf; the legacy Library stays at +`/prompt_manager/admin` by direct URL, linked only from System (#525, D165) (`tests/browser/workspace.spec.mjs`). `library comparison detects mutations` injects `.card-footer { padding: 2px }` and removes `.card-meta .chip.red`. @@ -3117,8 +3117,8 @@ npm run --prefix tools/vendor test:workflows -- history-concept.spec.mjs \ ### Inspect a file page and recorded deviations (#249 local Inspect, PR L2) `#/inspect` ("Inspect a file") is a native workspace route; it no longer hands off to -the legacy metadata page, which stays reachable from Overview's "Open existing metadata -inspector" card (`/prompt_manager/gallery.html`). The page is the concept's `#metadata` +the legacy metadata page, which stays at its URL +(`/prompt_manager/gallery.html`) linked only from System (#525, D165). The page is the concept's `#metadata` (app.js `renderMetadata` a740–748, selection a1715–1729; style.css 709–722, 1180–1194, 1543–1545, 1559–1564) in the concept's own classes (`.surface`, `.file-drop`, `.spaced-20`, `.local-file-preview`, all from the synced `concept.css`), with no @@ -7029,8 +7029,8 @@ Deploying this backend needs a ComfyUI restart (Python and route changes). ## Native vocabulary UI (#236) `#/tags` ("Tags & categories", page title "Tags") is a native workspace route; it no -longer hands off to the legacy tag manager. That page stays reachable from Overview's -"Open existing tag manager" card for its own page; tag search and sort, the untagged +longer hands off to the legacy tag manager. That page stays at +`/prompt_manager/admin#/tags` linked only from System (#525, D165); tag search and sort, the untagged entry (D149) and multi-tag OR (the Library's Tag match, D150) moved to the workspace (#444); bulk prompt delete stays on the legacy admin page (bulk tags: the Prompt library's selection bar, D151). A prompt's category is changed in the Prompt library @@ -7152,3 +7152,23 @@ until acceptance. Not compared: hover states and the dialog focus ring (the page keyboard ring and link ring offsets are asserted). No coverage floor was lowered; `vocabulary-state.mjs`, `vocabulary.mjs` and `vocabulary-dialog.mjs` have additive floors in `frontend-coverage-baseline.json`. + +## Cutover entry points (#525) + +The top-bar PM button, both Web UI buttons and `/prompt_manager/web` open +`/prompt_manager/workspace#/library` (`web/js/workspace-entry.js`; `web/index.html` keeps its +own copy, pinned by `tests/js/workspace-entry.test.js`). Overview's cards open native hashes. +The legacy pages (`/prompt_manager/admin`, `admin#/tags`, `gallery`, `gallery.html`) stay at +their URLs, and admin's Metadata and Gallery buttons still open their legacy pages (maintainer +ruling; pinned by `legacy-system-links.spec.mjs`). + +- **D165** (beyond the concept, maintainer ruling 2026-10-30): System's Overview section ends + with one small line, "Legacy pages: Dashboard · Tags · Gallery · Metadata viewer" + (`.legacy-pages`, new tabs), outside `.settings-layout`. The concept has no such line; + `mapOverviewConcept` adds it to the page tree. +- **Load failure** (maintainer ruling 2026-10-30): `web/workspace.html` holds static + `#workspace-boot-failure` with the same four links, styled inline so it needs no stylesheet. + An inline guard shows it at `load` unless `index.mjs` set `data-workspace-booted`, so a failed + fetch of any module and a throw while starting are both covered; `workspace.spec.mjs` tests + both. Editing `workspace.html` needs a ComfyUI restart, because the server caches it. The PM button has no +page-fixture test; it is proven on the managed host (`tests/comfyui/prompt-manager.spec.mjs`). diff --git a/tests/browser/classic-node.spec.mjs b/tests/browser/classic-node.spec.mjs index 0f6cdead..bd8d0b57 100644 --- a/tests/browser/classic-node.spec.mjs +++ b/tests/browser/classic-node.spec.mjs @@ -7,7 +7,7 @@ test.beforeEach(async ({page, browserName}) => { page.classicErrors = []; page.on('pageerror', error => page.classicErrors.push(error.message)); await page.route('**/scripts/app.js', route => route.fulfill({contentType:'text/javascript', body:'export const app = window.fixtureApp;'})); - await page.route('**/scripts/api.js', route => route.fulfill({contentType:'text/javascript', body:'export const api = {fileURL: path => path, queuePrompt: async (...args) => args};'})); + await page.route('**/scripts/api.js', route => route.fulfill({contentType:'text/javascript', body:'export const api = {fileURL: path => (window.fixtureApiBase ?? "") + path, queuePrompt: async (...args) => args};'})); await page.route('**/extensions/promptmanager/prompt_manager.js', route => route.fulfill({contentType:'text/javascript', body:readFileSync(new URL('../../web/comfy/prompt_manager.js', import.meta.url),'utf8')})); await page.goto('/prompt_manager/test'); await page.evaluate(() => { @@ -64,6 +64,28 @@ test('Classic keeps immediate selection, search query and metadata serialization await expect(page.getByRole('button',{name:'🌐 Web UI',exact:true})).toBeVisible(); }); +test('Classic Web UI opens the workspace Library in the saved display mode, through the mount prefix (#525)',async({page})=>{ + await page.evaluate(()=>{window.fixtureApiBase='/mounted';window.open=(...args)=>{window.openArgs=args;return {};};}); + const webUi=page.getByRole('button',{name:'🌐 Web UI',exact:true}); + for (const [mode,args] of [ + ['newtab',['/mounted/prompt_manager/workspace#/library','_blank']], + ['popup',['/mounted/prompt_manager/workspace#/library','_blank','width=1200,height=800,scrollbars=yes,resizable=yes']], + ]) { + await page.evaluate(value=>{node.properties.webuiDisplayMode=value;},mode); + await webUi.click(); + expect(await page.evaluate(()=>window.openArgs)).toEqual(args); + } +}); + +test('Classic Web UI reports a blocked popup and keeps the canvas open (#525)',async({page})=>{ + const before=page.url(); + await page.evaluate(()=>{window.open=()=>null;}); + await page.getByRole('button',{name:'🌐 Web UI',exact:true}).click(); + await expect(page.getByText('Allow popups to open Web UI, then try again.')).toBeVisible(); + expect(page.url()).toBe(before); + await expect(page.locator('.pm-classic')).toBeVisible(); +}); + test('Classic detaches native text synchronization on removal',async({page})=>{ // The page installs the text sync (serializeValue and its listeners) in a 50 ms timer; wait for it, not for time. await expect.poll(()=>page.evaluate(()=>typeof node.widgets.find(item=>item.name==='text').serializeValue)).toBe('function'); diff --git a/tests/browser/focused-node.spec.mjs b/tests/browser/focused-node.spec.mjs index 6ab2818c..00c4667c 100644 --- a/tests/browser/focused-node.spec.mjs +++ b/tests/browser/focused-node.spec.mjs @@ -485,7 +485,7 @@ test(`${variant} respects the existing Web UI display preference`,async({page})= }); await page.evaluate(()=>node._focusedSettingsReady); await page.getByRole('button',{name:'Web UI ↗',exact:true}).click(); - expect(await page.evaluate(()=>window.openArgs)).toEqual(['/prompt_manager/web','_blank']); + expect(await page.evaluate(()=>window.openArgs)).toEqual(['/prompt_manager/workspace#/library','_blank']); }); test(`${variant} reports blocked Web UI popups without leaving the workflow`,async({page})=>{ diff --git a/tests/browser/system-concept.spec.mjs b/tests/browser/system-concept.spec.mjs index e4320b90..f616b274 100644 --- a/tests/browser/system-concept.spec.mjs +++ b/tests/browser/system-concept.spec.mjs @@ -165,6 +165,8 @@ const number = value => value.toLocaleString('en-US'); /** The concept's shell and Overview with the recorded deviations applied, given the server's real overview. */ function mapOverviewConcept(reference, overview) { + // D165: the discreet legacy-pages line follows the settings layout. + reference = { ...reference, tree: [...reference.tree, 'p.legacy-pages.small'] }; const relabel = { 'Database integrity': 'Not checked', 'Positive prompts': number(overview.library.positive_prompts), 'Negative variants': number(overview.library.negative_variants), Images: number(overview.images.indexed) }; const rows = { @@ -257,7 +259,7 @@ const UNMAPPED_PATHS = [ 'columns.surfaces.0.rows.length', 'columns.surfaces.0.tree.4', 'columns.surfaces.0.tree.length', 'columns.surfaces.1.rows.0.description', 'columns.surfaces.1.rows.1.title', 'columns.surfaces.1.rows.2.description', 'heading.actions.0', 'heading.actions.length', 'metrics.items.3.value', 'nav.label', - 'save.button.cursor', 'save.button.opacity', 'save.status.text', + 'save.button.cursor', 'save.button.opacity', 'save.status.text', 'tree.3', 'tree.length', ].sort(); test('system comparison detects mutations', async ({ page, request }) => { diff --git a/tests/browser/workspace.spec.mjs b/tests/browser/workspace.spec.mjs index dff63d85..97f89b06 100644 --- a/tests/browser/workspace.spec.mjs +++ b/tests/browser/workspace.spec.mjs @@ -11,12 +11,12 @@ const sidebarLink = (page, label) => page.locator('aside.sidebar nav').getByRole const sidebarLinks = page => page.locator('aside.sidebar nav a'); const routes = [ ['overview', 'Overview', null], - // D29: Images no longer links the legacy gallery; Overview's tool cards still do. + // D29: Images no longer links the legacy gallery. ['images', 'Images', null], - // #249 E3: the shelf no longer links the legacy Library; Overview's tool card still does. + // #249 E3: the shelf no longer links the legacy Library. ['library', 'Prompt library', null], ['tags', 'Tags & categories', null], - // #249 local Inspect: the native Inspect a file page; Overview's tool card still links the legacy metadata page. + // #249 local Inspect: the native Inspect a file page. ['inspect', 'Inspect a file', null], // #249 System S2: native System; every section native since S12 (D94 retired, asserted below). ['system', 'System', null], @@ -93,20 +93,106 @@ test('all shell destinations deep-link, reload and expose working existing appli await page.reload(); await expect(main.getByRole('heading', { level: 1, name: title, exact: true })).toBeVisible(); } - // D29 and #249 E3: the legacy gallery and Library are reached from Overview's tool cards, not from their pages. + // #525: Overview's cards open the native pages, and no workspace page links a legacy one. + for (const [action, hash, title] of [ + ['Open Images', '#/images', 'Images'], + ['Open Prompt library', '#/library', 'Prompt library'], + ['Open Tags & categories', '#/tags', 'Tags & categories'], + ['Open Inspect a file', '#/inspect', 'Inspect a file'], + ['Open System', '#/system/overview', 'System'], + ]) { + await page.goto(`${workspace}#/overview`); + const main = page.locator('#workspace-main'); + const card = main.getByRole('link', { name: action, exact: true }); + await expect(card).toHaveAttribute('href', hash); + await card.click(); + await expect(page).toHaveURL(new RegExp(`/prompt_manager/workspace${hash}$`)); + await expect(main.getByRole('heading', { level: 1, name: title, exact: true })).toBeVisible(); + } await page.goto(`${workspace}#/overview`); - const library = page.locator('#workspace-main').getByRole('link', { name: 'Open existing prompt library', exact: true }); - await expect(library).toHaveAttribute('href', '/prompt_manager/admin'); - await expect(await request.get('/prompt_manager/admin')).toBeOK(); - // The legacy metadata page stays reachable from Overview beside the native Inspect page. - const metadata = page.locator('#workspace-main').getByRole('link', { name: 'Open existing metadata inspector', exact: true }); - await expect(metadata).toHaveAttribute('href', '/prompt_manager/gallery.html'); - await expect(await request.get('/prompt_manager/gallery.html')).toBeOK(); - const gallery = page.locator('#workspace-main').getByRole('link', { name: 'Open existing image gallery', exact: true }); - await expect(gallery).toHaveAttribute('href', '/prompt_manager/gallery'); - await expect(await request.get('/prompt_manager/gallery')).toBeOK(); - await gallery.click(); - await expect(page).toHaveURL(/\/prompt_manager\/gallery$/); + await expect(page.locator('#workspace-main').getByRole('link', { name: 'Open Images', exact: true })).toBeVisible(); + await expect(page.locator('a[href*="/prompt_manager/admin"], a[href*="/prompt_manager/gallery"]')).toHaveCount(0); + await expect(page.locator('#workspace-main').getByText('Your existing tools remain available')).toHaveCount(0); + // The legacy pages themselves stay at their URLs. + for (const legacy of ['/prompt_manager/admin', '/prompt_manager/gallery', '/prompt_manager/gallery.html']) { + await expect(await request.get(legacy)).toBeOK(); + } +}); + +test('/prompt_manager/web lands on the workspace Library and Back does not bounce through it (#525)', async ({ page }) => { + await page.goto(`${workspace}#/overview`); + await page.evaluate(() => { location.href = '/prompt_manager/web'; }); + await expect(page).toHaveURL(/\/prompt_manager\/workspace#\/library$/); + const main = page.locator('#workspace-main'); + await expect(main.getByRole('heading', { level: 1, name: 'Prompt library', exact: true })).toBeVisible(); + await page.goBack(); + await expect(page).toHaveURL(/\/prompt_manager\/workspace#\/overview$/); + await expect(main.getByRole('heading', { level: 1, name: 'Overview', exact: true })).toBeVisible(); +}); + +// D165 (#525, maintainer ruling): a discreet line under System is the workspace's only visible way to the legacy pages. +test('System Overview links the legacy pages discreetly, and each one loads (#525)', async ({ page, context }) => { + await page.goto(`${workspace}#/system/overview`); + const line = page.locator('#workspace-main .legacy-pages'); + await expect(line).toBeVisible(); + await expect(line).toContainText('Legacy pages'); + const links = line.getByRole('link'); + await expect(links).toHaveCount(4); + for (const [name, url, ready] of [ + ['Dashboard', '/prompt_manager/admin', '#addPromptBtn'], + ['Tags', '/prompt_manager/admin#/tags', '#addPromptBtn'], + ['Gallery', '/prompt_manager/gallery', 'body'], + ['Metadata viewer', '/prompt_manager/gallery.html', 'body'], + ]) { + const opened = context.waitForEvent('page'); + await line.getByRole('link', { name, exact: true }).click(); + const tab = await opened; + await tab.waitForLoadState('domcontentloaded'); + const target = new URL(tab.url()); + expect(target.pathname + target.hash).toBe(url); + await expect(tab.locator(ready).first()).toBeAttached(); + await tab.close(); + } + // Only on System's Overview section, and gone with the page. + await page.locator('#workspace-main .section-nav').getByRole('button', { name: 'Appearance', exact: true }).click(); + await expect(line).toBeHidden(); + await page.goto(`${workspace}#/library`); + await expect(page.locator('#workspace-main .legacy-pages')).toHaveCount(0); +}); + +const bootFailure = page => page.locator('#workspace-boot-failure'); + +test('a healthy workspace never shows the load failure message (#525)', async ({ page }) => { + await page.goto(`${workspace}#/library`); + await expect(page.locator('#workspace-main h1')).toHaveText('Prompt library'); + await page.waitForFunction(() => document.readyState === 'complete'); + await expect(bootFailure(page)).toBeHidden(); +}); + +for (const [label, pattern] of [['the entry module', '**/js/workspace/index.mjs*'], ['a dependency', '**/js/workspace/state.mjs*']]) { + test(`when ${label} fails to load the page still offers the legacy pages (#525)`, async ({ page }) => { + const pageErrors = []; + page.on('pageerror', error => pageErrors.push(error.message)); + await page.route(pattern, route => route.abort()); + await page.goto(`${workspace}#/library`); + const failure = bootFailure(page); + await expect(failure).toBeVisible(); + await expect(failure).toHaveAttribute('role', 'alert'); + const dashboard = failure.getByRole('link', { name: 'Dashboard', exact: true }); + await expect(dashboard).toBeVisible(); + await expect(failure.getByRole('link')).toHaveCount(4); + await dashboard.click(); + await expect(page).toHaveURL(/\/prompt_manager\/admin$/); + expect(pageErrors).toEqual([]); + }); +} + +test('when a module throws while starting the page still offers the legacy pages (#525)', async ({ page }) => { + await page.route('**/js/workspace/state.mjs*', route => route.fulfill({ contentType: 'text/javascript', body: "throw new Error('boot failure');" })); + await page.goto(`${workspace}#/library`); + await expect(bootFailure(page).getByRole('link', { name: 'Dashboard', exact: true })).toBeVisible(); + // The broken module's own error is this test's premise, not a failure of the page. + page.workspaceErrors.length = 0; }); test('keyboard navigation, browser history and unknown routes remain usable', async ({ page }) => { @@ -438,7 +524,7 @@ test('System is native: the concept shell, section navigation, sections and the // D92: no "Sample configuration" chip. await expect(main.locator('.heading .chip')).toHaveCount(0); expect(await main.evaluate(node => [...node.children].map(child => `${child.localName}.${child.className}`))) - .toEqual(['div.heading', 'div.section-nav', 'div.settings-layout']); + .toEqual(['div.heading', 'div.section-nav', 'div.settings-layout', 'p.small legacy-pages']); const nav = main.getByRole('group', { name: 'System sections', exact: true }); await expect(nav.getByRole('button')).toHaveText(SYSTEM_SECTION_LABELS); await expect(nav.getByRole('button', { name: 'Overview', exact: true })).toHaveAttribute('aria-pressed', 'true'); diff --git a/tests/comfyui/focused-node.spec.mjs b/tests/comfyui/focused-node.spec.mjs index 1f261934..ff7bafdd 100644 --- a/tests/comfyui/focused-node.spec.mjs +++ b/tests/comfyui/focused-node.spec.mjs @@ -210,14 +210,14 @@ test(`${label} follows actual ComfyUI light and dark palettes`, async ({ page }, } }); -test(`${label} Web UI button opens actual standalone pages`, async ({ page }) => { +test(`${label} Web UI button opens the workspace; legacy pages still load`, async ({ page }) => { await addEditor(page, type); const popupPromise = page.waitForEvent('popup'); await page.locator('.pm-focused').getByRole('button', { name: 'Web UI ↗', exact: true }).click(); const popup = await popupPromise; await popup.waitForLoadState('domcontentloaded'); - await expect(popup).toHaveURL(/\/prompt_manager\/(web|admin)/); - await expect(popup.locator('body')).toContainText('Prompt'); + await expect(popup).toHaveURL(/\/prompt_manager\/workspace#\/library$/); + await expect(popup.locator('#workspace-main h1')).toHaveText('Prompt library'); await expect(page.locator('.pm-focused').getByRole('status').filter({ hasText: 'Web UI opened.' })).toHaveText('Web UI opened.'); await popup.goto(new URL('/prompt_manager/gallery', page.url()).href); await expect(popup.locator('#addPromptBtn')).toBeVisible(); diff --git a/tests/comfyui/prompt-manager.spec.mjs b/tests/comfyui/prompt-manager.spec.mjs index 07ae1ce6..29e4d005 100644 --- a/tests/comfyui/prompt-manager.spec.mjs +++ b/tests/comfyui/prompt-manager.spec.mjs @@ -84,7 +84,7 @@ test('all node schemas and extension entrypoints load from the real host', async }); for (const type of ['PromptManager', 'PromptManagerText']) { - test(`${type} Web UI button opens actual standalone pages`, async ({ page }) => { + test(`${type} Web UI button opens the workspace; legacy pages still load`, async ({ page }) => { await page.evaluate(async type => { const { app } = await import('/scripts/app.js'); const node = LiteGraph.createNode(type); @@ -95,14 +95,74 @@ for (const type of ['PromptManager', 'PromptManagerText']) { await page.getByRole('button', { name: '🌐 Web UI', exact: true }).click(); const popup = await popupPromise; await popup.waitForLoadState('domcontentloaded'); - await expect(popup).toHaveURL(/\/prompt_manager\/(web|admin)/); - await expect(popup.locator('body')).toContainText('Prompt'); + await expect(popup).toHaveURL(/\/prompt_manager\/workspace#\/library$/); + await expect(popup.locator('#workspace-main h1')).toHaveText('Prompt library'); await popup.goto(new URL('/prompt_manager/gallery', page.url()).href); await expect(popup.locator('#addPromptBtn')).toBeVisible(); await popup.close(); }); } +test('top-bar PM button opens the workspace in either renderer', async ({ page }) => { + const button = page.locator('.promptmanager-top-button-group button, #pm-toolbar-btn').first(); + await expect(button).toBeVisible(); + const popupPromise = page.waitForEvent('popup'); + await button.click(); + const popup = await popupPromise; + await expect(popup).toHaveURL(/\/prompt_manager\/workspace#\/library$/); + await expect(popup.locator('#workspace-main h1')).toHaveText('Prompt library'); + await popup.close(); +}); + +for (const [type, buttonName] of [['PromptManager', '🌐 Web UI'], ['PromptManagerFocused', 'Web UI ↗'], ['PromptManagerPair', 'Web UI ↗']]) { + test(`${type} and the PM button open the workspace after switching renderers both ways, a workflow reload and node removal`, async ({ page }, testInfo) => { + const id = await page.evaluate(async type => { + const { app } = await import('/scripts/app.js'); + const node = LiteGraph.createNode(type); + app.graph.add(node); + node.setSize([900, 800]); + app.canvas.centerOnNode(node); + return node.id; + }, type); + const webUi = page.getByRole('button', { name: buttonName, exact: true }); + const pm = page.locator('.promptmanager-top-button-group button, #pm-toolbar-btn').first(); + const opensWorkspace = async button => { + await expect(button).toHaveCount(1); + await expect(button).toBeVisible(); + const popupPromise = page.waitForEvent('popup'); + await button.click(); + const popup = await popupPromise; + await expect(popup).toHaveURL(/\/prompt_manager\/workspace#\/library$/); + await expect(popup.locator('#workspace-main h1')).toHaveText('Prompt library'); + await popup.close(); + }; + await opensWorkspace(webUi); + for (const renderer of [!testInfo.project.metadata.initialRenderer, testInfo.project.metadata.initialRenderer]) { + await page.evaluate(async renderer => { + const { app } = await import('/scripts/app.js'); + await app.ui.settings.setSettingValue('Comfy.VueNodes.Enabled', renderer); + app.canvas.ds.scale = .8; + app.canvas.centerOnNode(app.graph._nodes[0]); + }, renderer); + await expect.poll(() => page.evaluate(() => LiteGraph.vueNodesMode)).toBe(renderer); + await opensWorkspace(webUi); + await opensWorkspace(pm); + } + await page.evaluate(async () => { + const { app } = await import('/scripts/app.js'); + await app.loadGraphData(app.graph.serialize()); + }); + await opensWorkspace(webUi); + await opensWorkspace(pm); + await page.evaluate(async id => { + const { app } = await import('/scripts/app.js'); + app.graph.remove(app.graph.getNodeById(id)); + }, id); + await expect(webUi).toHaveCount(0); + await opensWorkspace(pm); + }); +} + test('Classic controls follow actual ComfyUI light and dark palettes', async ({ page }, testInfo) => { await page.evaluate(async () => { const { app } = await import('/scripts/app.js'); diff --git a/tests/comfyui/workspace.spec.mjs b/tests/comfyui/workspace.spec.mjs index c4c1b458..00d2c8d4 100644 --- a/tests/comfyui/workspace.spec.mjs +++ b/tests/comfyui/workspace.spec.mjs @@ -37,11 +37,11 @@ test('workspace serves real library data beside either ComfyUI renderer', async await expect(workspace.getByText('No matching images · 0 images', { exact: true })).toBeVisible(); await expect(workspace.locator('.gallery-body > .empty')).toHaveText('No matching images. Try another search or category.'); } - // D29: Images no longer links the legacy gallery; Overview's "Open existing image gallery" card does. + // D29: Images no longer links the legacy gallery; #525: nor does Overview. The page stays at its URL. await expect(workspace.locator('#workspace-main a[href="/prompt_manager/gallery"]')).toHaveCount(0); await workspace.evaluate(() => { location.hash = '/overview'; }); - const gallery = workspace.locator('#workspace-main').getByRole('link', { name: 'Open existing image gallery', exact: true }); - await expect(gallery).toHaveAttribute('href', '/prompt_manager/gallery'); + const imagesCard = workspace.locator('#workspace-main').getByRole('link', { name: 'Open Images', exact: true }); + await expect(imagesCard).toHaveAttribute('href', '#/images'); expect((await workspace.request.get('/prompt_manager/gallery')).ok()).toBe(true); await workspace.evaluate(() => { location.hash = '/images'; }); await workspace.reload(); diff --git a/tests/js/workspace-entry.test.js b/tests/js/workspace-entry.test.js new file mode 100644 index 00000000..aec1b674 --- /dev/null +++ b/tests/js/workspace-entry.test.js @@ -0,0 +1,34 @@ +// #525: where the canvas buttons open PromptManager (web/js/workspace-entry.js). +const test = require('node:test'); +const assert = require('node:assert/strict'); +const fs = require('node:fs'); +const path = require('node:path'); +const source = import('../../web/js/workspace-entry.js'); +const state = import('../../web/js/workspace/state.mjs'); + +test('the canvas entry route is the workspace Library, a native route', async () => { + const { WORKSPACE_ENTRY_ROUTE } = await source; + const { resolveRoute } = await state; + assert.equal(WORKSPACE_ENTRY_ROUTE, '/prompt_manager/workspace#/library'); + const [pathname, hash] = WORKSPACE_ENTRY_ROUTE.split('#'); + assert.equal(pathname, '/prompt_manager/workspace'); + assert.equal(resolveRoute(`#${hash}`).id, 'library'); +}); + +test('/prompt_manager/web redirects to the same route, under any mount prefix', async () => { + const { WORKSPACE_ENTRY_ROUTE } = await source; + const html = fs.readFileSync(path.join(__dirname, '../../web/index.html'), 'utf8'); + const [, pattern, flags, replacement] = html.match(/location\.replace\(\s*window\.location\.pathname\.replace\(\/(.+)\/([a-z]*), '([^']+)'\)/); + const redirect = pathname => pathname.replace(new RegExp(pattern, flags), replacement); + assert.equal(redirect('/prompt_manager/web'), WORKSPACE_ENTRY_ROUTE); + assert.equal(redirect('/prompt_manager/web/'), WORKSPACE_ENTRY_ROUTE); + assert.equal(redirect('/prompt_manager/index.html'), WORKSPACE_ENTRY_ROUTE); + assert.equal(redirect('/sub/prompt_manager/web'), `/sub${WORKSPACE_ENTRY_ROUTE}`); +}); + +test('no canvas entry point still names a legacy page', () => { + for (const file of ['web/comfy/prompt_manager.js', 'web/js/focused-node.js', 'web/js/classic-node.js']) { + const code = fs.readFileSync(path.join(__dirname, '../..', file), 'utf8'); + assert.doesNotMatch(code, /\/prompt_manager\/(?:admin|gallery|web)\b/, file); + } +}); diff --git a/tests/js/workspace-state.test.js b/tests/js/workspace-state.test.js index 526cbe70..6b818430 100644 --- a/tests/js/workspace-state.test.js +++ b/tests/js/workspace-state.test.js @@ -17,7 +17,7 @@ test('tags is a native route titled Tags; only unbuilt routes hand off to legacy const tags = resolveRoute('#/tags'); assert.equal(tags.label, 'Tags & categories'); assert.equal(routeTitle(tags), 'Tags'); - assert.equal(tags.href, '/prompt_manager/admin#/tags', 'Overview keeps the existing tag manager card'); + assert.equal(tags.href, '#/tags', 'Overview opens the native page (#525)'); for (const id of ['images', 'library', 'inspect', 'overview', 'system']) { const route = resolveRoute(`#/${id}`); assert.equal(routeTitle(route), route.label); @@ -48,7 +48,6 @@ test('system resolves its sections; #/overview stays the production hub', async // Q12: Overview's System card opens the native System Overview, with no legacy href or action. const card = routes.find(route => route.id === 'system'); assert.equal(card.href, '#/system/overview'); - assert.equal(card.native, true); assert.equal(card.action, 'Open System'); }); @@ -77,9 +76,28 @@ test('inspect is the native Inspect a file page with the concept description', a // The concept's heading description (app.js renderMetadata 743). assert.equal(inspect.description, 'A standalone file uses the same image viewer and prompt actions.'); assert.equal(isHandoff(inspect), false); - // Overview keeps its card for the legacy metadata page, which stays working. - assert.equal(inspect.href, '/prompt_manager/gallery.html'); - assert.equal(inspect.action, 'Open existing metadata inspector'); + // Overview's card opens the native page (#525); the legacy metadata page stays at its URL. + assert.equal(inspect.href, '#/inspect'); + assert.equal(inspect.action, 'Open Inspect a file'); +}); + +test('every Overview card opens its own native page, never a legacy one (#525)', async () => { + const { resolveRoute, routes } = await source; + const cards = routes.filter(route => route.href); + assert.deepEqual(cards.map(card => card.id), ['images', 'library', 'tags', 'inspect', 'system']); + for (const card of cards) { + assert.match(card.href, /^#\//, card.id); + assert.equal(resolveRoute(card.href).id, card.id); + assert.match(card.action, /^Open /, card.id); + assert.doesNotMatch(card.action, /existing/, card.id); + } +}); + +test('the legacy pages System links are the four that stay at their URLs (#525 D165)', async () => { + const { LEGACY_PAGES } = await source; + assert.deepEqual(LEGACY_PAGES.map(page => page.href), ['/prompt_manager/admin', '/prompt_manager/admin#/tags', + '/prompt_manager/gallery', '/prompt_manager/gallery.html']); + for (const page of LEGACY_PAGES) assert.ok(page.label); }); test('a job of the Activity page is its own deep link', async () => { diff --git a/tests/vendor/comfyui-config.test.mjs b/tests/vendor/comfyui-config.test.mjs index b77e7d8b..8b81a4ce 100644 --- a/tests/vendor/comfyui-config.test.mjs +++ b/tests/vendor/comfyui-config.test.mjs @@ -25,7 +25,8 @@ test('execution script discovers only the new matrix and preserves all 78 origin // + local Inspect host case (inspect-sender.spec.mjs, 2 projects), + the S10 renderer case (prompt-bridge.spec.mjs, 2 projects) // + #343 linked text: five node types, PromptSearchList, the pre-#343 workflows, legacy Primitive, moved link, // paste/clone/undo, wildcard Primitive, subgraph Primitive (text-link.spec.mjs, 2 projects) - assert.match(stock, /Total: 110 tests in 9 files/); + // + #525 the PM button and three nodes' Web UI after renderer switches and reload (prompt-manager.spec.mjs, 2 projects) + assert.match(stock, /Total: 118 tests in 9 files/); assert.doesNotMatch(stock, /execution-history/); assert.doesNotMatch(stock, /execution-outputs/); assert.doesNotMatch(stock, /library-location/); // it starts its own fresh host (#249 S6b) diff --git a/tools/vendor/frontend-coverage-baseline.json b/tools/vendor/frontend-coverage-baseline.json index 33a431e5..5cd0a1ac 100644 --- a/tools/vendor/frontend-coverage-baseline.json +++ b/tools/vendor/frontend-coverage-baseline.json @@ -107,6 +107,12 @@ "functions": 98, "statements": 98 }, + "workspace-entry.js": { + "lines": 80, + "branches": 80, + "functions": 80, + "statements": 80 + }, "save-to-library.js": { "lines": 98, "branches": 98, diff --git a/web/comfy/prompt_manager.js b/web/comfy/prompt_manager.js index 706c9d78..ddd36e5e 100644 --- a/web/comfy/prompt_manager.js +++ b/web/comfy/prompt_manager.js @@ -7,6 +7,7 @@ const { installFocusedNode, installSplitNode, installPairNode } = await import(a const { installPromptBridge } = await import(api.fileURL('/prompt_manager/js/prompt-bridge-host.js')); const { collapseLinkedWidget, isLinked, linkNoteText, primitiveTextValue, storedWidgetValue } = await import(api.fileURL('/prompt_manager/js/text-links.js')); const { normalizeSaveWidget } = await import(api.fileURL('/prompt_manager/js/save-to-library.js')); +const { WORKSPACE_ENTRY_ROUTE } = await import(api.fileURL('/prompt_manager/js/workspace-entry.js')); const promptManagerTypes = new Set(['PromptManager', 'PromptManagerText', 'PromptManagerFocused', 'PromptManagerSplit', 'PromptManagerPair']); // ============================================================================ @@ -109,7 +110,7 @@ function addPMTopBarButton() { // Create PM button const pmButton = $el("button", { classList: "comfyui-button comfyui-menu-mobile-collapse primary", - title: "PromptManager - Open Admin Dashboard", + title: "PromptManager - Open workspace", style: { display: "flex", alignItems: "center", @@ -125,7 +126,7 @@ function addPMTopBarButton() { transition: "background 0.2s", }, onclick: () => { - window.open(api.fileURL("/prompt_manager/admin"), "_blank"); + window.open(api.fileURL(WORKSPACE_ENTRY_ROUTE), "_blank"); }, onmouseenter: (e) => { e.target.style.background = "#0d9488"; }, onmouseleave: (e) => { e.target.style.background = "#14b8a6"; }, @@ -161,7 +162,7 @@ function addPMTopBarButtonLegacy() { const btn = document.createElement("button"); btn.id = "pm-toolbar-btn"; btn.innerText = "PM"; - btn.title = "Open PromptManager Admin Dashboard"; + btn.title = "Open PromptManager workspace"; // Teal button styling btn.style.cssText = ` @@ -181,7 +182,7 @@ function addPMTopBarButtonLegacy() { btn.onmouseleave = () => btn.style.background = "#14b8a6"; btn.onclick = () => { - window.open(api.fileURL("/prompt_manager/admin"), "_blank"); + window.open(api.fileURL(WORKSPACE_ENTRY_ROUTE), "_blank"); }; topBar.appendChild(btn); @@ -1184,9 +1185,7 @@ app.registerExtension({ // Method to open web interface nodeType.prototype.openWebInterface = function () { try { - // Open the web interface served via our API endpoint - const currentOrigin = window.location.origin; - const webUrl = `${currentOrigin}/prompt_manager/web`; + const webUrl = api.fileURL(WORKSPACE_ENTRY_ROUTE); if (this.properties.webuiDisplayMode === "newtab") { // Open in new tab without window options @@ -1198,7 +1197,8 @@ app.registerExtension({ "success", ); } else { - window.location.href = webUrl; + // Never navigate the canvas away: it may hold unsaved work. + this.showNotification("Allow popups to open Web UI, then try again.", "error"); } } else { // Default popup mode with specific dimensions @@ -1211,7 +1211,8 @@ app.registerExtension({ if (newWindow) { this.showNotification("Web interface opened in popup", "success"); } else { - window.location.href = webUrl; + // Never navigate the canvas away: it may hold unsaved work. + this.showNotification("Allow popups to open Web UI, then try again.", "error"); } } } catch (error) { diff --git a/web/index.html b/web/index.html index 57806727..4e7f7f88 100644 --- a/web/index.html +++ b/web/index.html @@ -3,7 +3,7 @@ - PromptManager - Redirecting to Admin + PromptManager - Opening the workspace