From 47d53fd95d972337fd0de48e2b857076b4796308 Mon Sep 17 00:00:00 2001 From: Ettore Di Giacinto Date: Tue, 20 Oct 2026 14:59:45 +0000 Subject: [PATCH] test(ui): run the Studio and Talk specs against the add-on bundles The pages are add-ons now, so a spec that draws one installs the real built bundle first: the apps list names it and its module and style sheet are served from the build output, the way the add-on owner serves them. studio-addon.js holds that helper, and the Studio and Talk fixtures call it. Update the specs for the new addresses (/app/addons/studio/studio, the model as ?model=), the session permission the add-on route needs, the legacy redirects, the Home /studio command that appears only while the add-on is installed, and the sidebar without a Create group. Add specs for the add-ons themselves: every tab draws with no script error, the shell titles the page once, the old addresses say so when the add-on is not installed, a button of the page reaches a core page with the way back, and the drawer lists both only after they are installed. Assisted-by: Claude Code:claude-sonnet-5-5 node Signed-off-by: Ettore Di Giacinto --- .../react-ui/e2e/addon-navigation.spec.js | 36 ++++-- .../http/react-ui/e2e/audio-transform.spec.js | 3 + core/http/react-ui/e2e/chat-fixtures.js | 3 + core/http/react-ui/e2e/chrome-audit.spec.js | 3 + .../react-ui/e2e/diarization-profiles.spec.js | 5 +- core/http/react-ui/e2e/home-console.spec.js | 25 +++- core/http/react-ui/e2e/identity.spec.js | 2 +- .../react-ui/e2e/legacy-redirects.spec.js | 18 +-- core/http/react-ui/e2e/navigation.spec.js | 6 +- core/http/react-ui/e2e/no-edge-rails.spec.js | 3 + .../react-ui/e2e/page-header-no-crumb.spec.js | 1 - .../react-ui/e2e/page-render-smoke.spec.js | 30 ++++- core/http/react-ui/e2e/studio-addon.js | 53 +++++++++ core/http/react-ui/e2e/studio-addons.spec.js | 110 ++++++++++++++++++ .../http/react-ui/e2e/studio-composer.spec.js | 16 +-- core/http/react-ui/e2e/studio-fixtures.js | 6 +- core/http/react-ui/e2e/studio-lineage.spec.js | 10 +- .../http/react-ui/e2e/studio-overview.spec.js | 8 +- .../react-ui/e2e/studio-transform.spec.js | 3 + .../react-ui/e2e/studio-workbench.spec.js | 3 + .../react-ui/e2e/studio-workspace.spec.js | 8 +- core/http/react-ui/e2e/talk.spec.js | 4 + .../react-ui/e2e/threed-animation.spec.js | 3 + core/http/react-ui/e2e/threed-gen.spec.js | 5 +- 24 files changed, 308 insertions(+), 56 deletions(-) create mode 100644 core/http/react-ui/e2e/studio-addon.js create mode 100644 core/http/react-ui/e2e/studio-addons.spec.js diff --git a/core/http/react-ui/e2e/addon-navigation.spec.js b/core/http/react-ui/e2e/addon-navigation.spec.js index d53fe0a7b..09450158a 100644 --- a/core/http/react-ui/e2e/addon-navigation.spec.js +++ b/core/http/react-ui/e2e/addon-navigation.spec.js @@ -3,18 +3,21 @@ import fs from 'node:fs' import path from 'node:path' import { fileURLToPath } from 'node:url' import { ADMIN, MEMBER, app, sessionFor, mockAddons } from './addons-fixtures.js' +import { installStudioAddons, packagedApp } from './studio-addon.js' import { FACE_MODEL, clipFile, faces, match, mockIdentity, people, photoFile } from './identity-fixtures.js' -const STUDIO = { id: 'studio', kind: 'page', titleKey: 'items.studio', icon: 'palette', mainPath: '/app/studio' } -const TALK = { id: 'talk', kind: 'page', titleKey: 'items.talk', icon: 'phone', mainPath: '/app/talk' } - -async function setup(page, { apps = [], pins = ['studio', 'talk'], addons = true, builtin = [STUDIO, TALK] } = {}) { +// Studio and Talk are packaged add-ons, not built-in tiles: the specs install +// the real packages. No page is built in today, so `builtin` is empty unless a +// spec makes one up to check the built-in rules. +async function setup(page, { apps = [], pins = ['studio', 'talk'], addons = true, builtin = [], studio = true } = {}) { let saved = pins await page.route('**/api/auth/status', route => route.fulfill({ json: sessionFor(MEMBER, { addons }) })) await page.route('**/api/features', route => route.fulfill({ json: { addon_navigation: true } })) await page.route('**/api/addons/builtin', route => route.fulfill({ json: { builtin } })) // mockAddons installs the Apps routes with the real response shape ({ apps }). - await mockAddons(page, { apps }) + // The packages are served from the build output, so a page that opens one runs it. + if (studio) await installStudioAddons(page, { apps: [packagedApp('studio'), packagedApp('talk'), ...apps] }) + else await mockAddons(page, { apps }) await page.route('**/api/addons/pins', async route => { if (route.request().method() === 'PUT') saved = route.request().postDataJSON() await route.fulfill({ json: saved }) @@ -81,22 +84,33 @@ test.describe('Add-on navigation', () => { test('member without the add-ons feature sees the built-in tiles and no error', async ({ page }) => { let appsCalled = false - await setup(page, { addons: false }) + // No page is built in today, so the spec makes one up. + await setup(page, { addons: false, builtin: [{ id: 'guide', kind: 'page', titleKey: 'items.chat', icon: 'chat', mainPath: '/app/chat' }] }) // Registered after setup, so it wins over the mocked Apps route. await page.route(/\/api\/addons\/apps$/, route => { appsCalled = true; return route.fulfill({ status: 403, json: {} }) }) await page.goto('/app/addons') - await expect(page.getByTestId('addon-tile')).toHaveCount(2) + await expect(page.getByTestId('addon-tile')).toHaveCount(1) await expect(page.getByTestId('addons-browse')).toHaveCount(0) await expect(page.getByRole('alert')).toHaveCount(0) expect(appsCalled).toBe(false) }) + test('member without the add-ons feature and with no built-in tile goes home, and Studio and Talk are not built in', async ({ page }) => { + await setup(page, { addons: false }) + await page.goto('/app/addons') + await expect(page).toHaveURL(/\/app$/) + await expect(page.getByRole('navigation').getByRole('link', { name: 'Add-ons' })).toHaveCount(0) + }) + test('the legacy flag brings the old sidebar back', async ({ page }) => { await setup(page) await page.route('**/api/features', route => route.fulfill({ json: { addon_navigation: false } })) await page.goto('/app') await expect(page.getByTestId('pinned-nav')).toHaveCount(0) - await expect(page.getByRole('navigation').getByRole('link', { name: 'Studio' })).toBeVisible() + // Home, Models and Chat head the old sidebar; Studio and Talk are add-ons and are not in it. + await expect(page.getByRole('navigation').getByRole('link', { name: 'Chat' })).toBeVisible() + await expect(page.getByRole('navigation').getByRole('link', { name: 'Studio' })).toHaveCount(0) + await expect(page.locator('.sidebar-section-title', { hasText: 'Create' })).toHaveCount(0) }) test('the layout holds at phone width', async ({ page }) => { @@ -132,7 +146,7 @@ test.describe('Add-on navigation', () => { // been drawn from, not before, when a missing pin proves nothing. await page.route(/\/api\/addons\/apps$/, async route => { await new Promise(resolve => setTimeout(resolve, 4000)) - await route.fulfill({ json: { apps: [] } }) + await route.fulfill({ json: { apps: [packagedApp('studio')] } }) }) const listed = page.waitForResponse(res => /\/api\/addons\/apps$/.test(res.url())) await page.goto('/app') @@ -191,13 +205,13 @@ test.describe('Add-on navigation: the Add-ons hub tab and the route in both layo test('an old generator link keeps its model, query and hash', async ({ page }) => { await setup(page) await page.goto('/app/image/stable%2Fdiffusion?x=1#top') - await expect(page).toHaveURL(/\/app\/studio\/images\/stable%2Fdiffusion\?x=1#top$/) + await expect(page).toHaveURL(/\/app\/addons\/studio\/studio\/images\?x=1&model=stable%2Fdiffusion#top$/) }) test('the legacy ?tab= link of Studio still works', async ({ page }) => { await setup(page) await page.goto('/app/studio?tab=tts') - await expect(page).toHaveURL(/\/app\/studio\/tts$/) + await expect(page).toHaveURL(/\/app\/addons\/studio\/studio\/tts$/) }) test('a viewer without the 3d feature still ends on the app home', async ({ page }) => { diff --git a/core/http/react-ui/e2e/audio-transform.spec.js b/core/http/react-ui/e2e/audio-transform.spec.js index 306ae77af..8ec08d300 100644 --- a/core/http/react-ui/e2e/audio-transform.spec.js +++ b/core/http/react-ui/e2e/audio-transform.spec.js @@ -1,4 +1,5 @@ import { test, expect } from './coverage-fixtures.js' +import { installStudioAddons } from './studio-addon.js' function mockCapabilities(page, capabilities) { return page.route('**/api/models/capabilities', (route) => { @@ -95,6 +96,8 @@ function makeToneWav(name, freq = 1000, seconds = 0.4, sampleRate = 16000) { test.describe('Audio Transform', () => { test.beforeEach(async ({ page }) => { + // Studio is an add-on: the page opens inside it. + await installStudioAddons(page, { ids: ['studio'] }) await mockCapabilities(page, [ { id: 'localvqe', capabilities: ['FLAG_AUDIO_TRANSFORM'] }, ]) diff --git a/core/http/react-ui/e2e/chat-fixtures.js b/core/http/react-ui/e2e/chat-fixtures.js index 5fc80b04c..764eadff1 100644 --- a/core/http/react-ui/e2e/chat-fixtures.js +++ b/core/http/react-ui/e2e/chat-fixtures.js @@ -2,6 +2,7 @@ // written to the browser storage the Chat page reads, and a completion stream // the spec drives by hand. import { mockHome, seedChats, CHATS_KEY } from './home-fixtures.js' +import { installStudioAddons } from './studio-addon.js' export { CHATS_KEY, seedChats } @@ -106,6 +107,8 @@ export const PNG = 'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAA // The Talk page's WebRTC, faked: the page connects as it would, and the spec // plays the server's events with window.__talk.emit(event). export async function fakeRealtime(page, { pipelines = true, micError = null } = {}) { + // Talk is an add-on: install the built bundle that draws the page. + await installStudioAddons(page, { ids: ['talk'] }) await page.route('**/api/pipeline-models', route => route.fulfill({ json: pipelines ? [ diff --git a/core/http/react-ui/e2e/chrome-audit.spec.js b/core/http/react-ui/e2e/chrome-audit.spec.js index fcebdfc34..1a85ec249 100644 --- a/core/http/react-ui/e2e/chrome-audit.spec.js +++ b/core/http/react-ui/e2e/chrome-audit.spec.js @@ -1,4 +1,5 @@ import { test, expect } from './coverage-fixtures.js' +import { installStudioAddons } from './studio-addon.js' // A standing guard against the two defects an earlier automated edit left // scattered through the pages: icons that render nothing at all (here, an svg @@ -14,6 +15,8 @@ const ROUTES = [ ] test('no page renders a dead icon or a default-chrome control', async ({ page }) => { + // Studio and Talk are add-ons; /app/studio and /app/talk open them. + await installStudioAddons(page) // One test walks every route, so its budget has to scale with the list rather // than sit on Playwright's per-test default of 30s. At 25 routes that default // allows ~1.2s per navigation, which holds on a developer machine and does diff --git a/core/http/react-ui/e2e/diarization-profiles.spec.js b/core/http/react-ui/e2e/diarization-profiles.spec.js index 7619245fe..2bb00ea49 100644 --- a/core/http/react-ui/e2e/diarization-profiles.spec.js +++ b/core/http/react-ui/e2e/diarization-profiles.spec.js @@ -1,5 +1,6 @@ // SPDX-License-Identifier: MIT import { test, expect } from './coverage-fixtures.js' +import { installStudioAddons } from './studio-addon.js' const profiles = { version: 1, encoder: { identity: `sha256:${'a'.repeat(64)}`, dimension: 2 }, speakers: [ { speaker: 9, clean_duration: 0, intervals: [], unavailable_reason: 'insufficient_clean_speech' }, @@ -21,11 +22,13 @@ const file = { name: 'meeting.wav', mimeType: 'audio/wav', buffer: Buffer.alloc( async function setup(page, permission = true, diarizationPermission = true) { await page.route('**/api/**', route => { const url = route.request().url() - const data = url.endsWith('/auth/status') ? { authEnabled: true, user: { role: 'user', permissions: { audio_diarization: diarizationPermission, voice_recognition: permission } } } + const data = url.endsWith('/auth/status') ? { authEnabled: true, user: { role: 'user', permissions: { addons: true, audio_diarization: diarizationPermission, voice_recognition: permission } } } : url.endsWith('/models/capabilities') ? { data: ['diarizer', 'other'].map(id => ({ id, capabilities: ['FLAG_DIARIZATION'] })) } : {} return route.fulfill({ json: data }) }) await page.route('**/v1/audio/diarization', route => route.fulfill({ json: result })) + // Studio is an add-on. Installed after the catch-all above, so its routes win. + await installStudioAddons(page, { ids: ['studio'] }) await page.goto('/app/diarization') if (!diarizationPermission) return await expect(page.getByRole('button', { name: 'diarizer', exact: true })).toBeVisible() diff --git a/core/http/react-ui/e2e/home-console.spec.js b/core/http/react-ui/e2e/home-console.spec.js index af48f675c..0d8c076ae 100644 --- a/core/http/react-ui/e2e/home-console.spec.js +++ b/core/http/react-ui/e2e/home-console.spec.js @@ -1,5 +1,6 @@ import { test, expect } from './coverage-fixtures.js' import { mockHome, mockUser, seedChats, sampleChats, CHATS_KEY, LOADED } from './home-fixtures.js' +import { installStudioAddons } from './studio-addon.js' // The Home console: command bar, slash menu, memory strip, resume list, first // run and the phone layout. Data is stubbed the way the other Home specs do it; @@ -39,10 +40,26 @@ test.describe('Home slash menu', () => { expect(names).toEqual([ '/model', '/new', '/chat', '/assistant', '/gallery', '/installed', '/import', '/stop', - '/studio', '/settings', '/docs', + '/settings', '/docs', ]) }) + // Studio is an add-on: its command is offered only while it is installed. + test('the /studio command appears once the Studio add-on is installed', async ({ page }) => { + await installStudioAddons(page, { ids: ['studio'] }) + await ready(page) + await page.locator('.home-textarea').fill('/') + await expect(page.getByTestId('home-slash-studio')).toBeVisible() + await page.getByTestId('home-slash-studio').click() + await expect(page).toHaveURL(/\/app\/addons\/studio\/studio/) + }) + + test('the /studio command is not offered while the Studio add-on is not installed', async ({ page }) => { + await ready(page) + await page.locator('.home-textarea').fill('/stu') + await expect(page.getByTestId('home-slash-studio')).toHaveCount(0) + }) + test('filters as you type and marks the typed part', async ({ page }) => { await ready(page) await page.locator('.home-textarea').fill('/ga') @@ -93,8 +110,8 @@ test.describe('Home slash menu', () => { test('a click on an action runs it and clears the box', async ({ page }) => { await ready(page) await page.locator('.home-textarea').fill('/') - await page.getByTestId('home-slash-studio').click() - await expect(page).toHaveURL(/\/app\/studio/) + await page.getByTestId('home-slash-installed').click() + await expect(page).toHaveURL(/\/app\/models\?view=installed/) }) test('"/" outside a text field starts a command', async ({ page }) => { @@ -138,7 +155,7 @@ test.describe('Home slash menu', () => { await ready(page) await page.locator('.home-textarea').fill('/') const names = await page.locator('.dk-cmd-name').allTextContents() - expect(names).toEqual(['/model', '/new', '/chat', '/studio', '/docs']) + expect(names).toEqual(['/model', '/new', '/chat', '/docs']) }) test('and the library row keeps only the documentation link', async ({ page }) => { diff --git a/core/http/react-ui/e2e/identity.spec.js b/core/http/react-ui/e2e/identity.spec.js index e4f2f238c..792cdbe07 100644 --- a/core/http/react-ui/e2e/identity.spec.js +++ b/core/http/react-ui/e2e/identity.spec.js @@ -389,7 +389,7 @@ test.describe('Voices: states of the page', () => { await page.goto('/app/voice?tab=recording') await expect(page.getByTestId('recording-tab')).toContainText('Name the speakers in a recording') await page.getByRole('link', { name: /Open the diarization workspace/ }).click() - await expect(page).toHaveURL(/\/app\/studio\/diarization/) + await expect(page).toHaveURL(/\/app\/addons\/studio\/studio\/diarization/) }) test('the route /app/voice/:model keeps working', async ({ page }) => { diff --git a/core/http/react-ui/e2e/legacy-redirects.spec.js b/core/http/react-ui/e2e/legacy-redirects.spec.js index a52179ec8..1a50c2d93 100644 --- a/core/http/react-ui/e2e/legacy-redirects.spec.js +++ b/core/http/react-ui/e2e/legacy-redirects.spec.js @@ -1,23 +1,23 @@ import { test, expect } from '@playwright/test' -import { MEMBER, sessionFor, mockAddons } from './addons-fixtures.js' +import { MEMBER, sessionFor } from './addons-fixtures.js' +import { installStudioAddons } from './studio-addon.js' import { MIGRATION_ROUTES } from '../src/utils/legacyRoutes.js' // Every moved generator route, with and without a model: 14 old paths. For -// each, in both flag states, the final location is the Studio tab and that -// generator is drawn (its tab is active), not the Studio overview fallback. +// each, in both flag states, the final location is the tab of the Studio add-on +// and that generator is drawn (its tab is active), not the overview fallback. +// A model travels as ?model=, because an add-on path segment refuses "/" and ":". const MOVED = MIGRATION_ROUTES.filter(r => r.kind === 'studio-tab') const CASES = MOVED.flatMap(r => [ { from: r.from, to: r.to, tab: r.to.split('/').pop() }, - { from: `${r.from}/my-model`, to: `${r.to}/my-model`, tab: r.to.split('/').pop() }, + { from: `${r.from}/my-model`, to: `${r.to}?model=my-model`, tab: r.to.split('/').pop() }, ]) -const STUDIO = { id: 'studio', kind: 'page', titleKey: 'items.studio', icon: 'palette', mainPath: '/app/studio' } - async function setup(page, flag) { await page.route('**/api/auth/status', route => route.fulfill({ json: sessionFor(MEMBER, { addons: true, '3d': true, audio_diarization: true, audio_transform: true }) })) await page.route('**/api/features', route => route.fulfill({ json: { addon_navigation: flag } })) - await page.route('**/api/addons/builtin', route => route.fulfill({ json: { builtin: [STUDIO] } })) - await mockAddons(page, { apps: [] }) + await page.route('**/api/addons/builtin', route => route.fulfill({ json: { builtin: [] } })) + await installStudioAddons(page, { ids: ['studio'] }) await page.route('**/api/addons/pins', route => route.fulfill({ json: [] })) } @@ -31,7 +31,7 @@ for (const flag of [true, false]) { test(`${c.from} opens ${c.to}`, async ({ page }) => { await setup(page, flag) await page.goto(c.from) - await expect(page).toHaveURL(new RegExp(`${c.to.replace(/\//g, '\\/')}$`)) + await expect(page).toHaveURL(new RegExp(`${c.to.replace(/[/?]/g, '\\$&')}$`)) await expect(page.locator(`.studio-tab-active[data-tab="${c.tab}"]`)).toBeVisible() await expect(page.getByTestId('studio-overview')).toHaveCount(0) }) diff --git a/core/http/react-ui/e2e/navigation.spec.js b/core/http/react-ui/e2e/navigation.spec.js index 30bb85940..da2a89ed2 100644 --- a/core/http/react-ui/e2e/navigation.spec.js +++ b/core/http/react-ui/e2e/navigation.spec.js @@ -26,8 +26,10 @@ test.describe('Navigation', () => { for (const href of ['/app/chat', '/app/models', '/app/addons', '/app/settings']) { await expect(page.locator(`.sidebar-nav a.nav-item[href="${href}"]`)).toBeVisible() } - // Studio and Talk are built-in tiles of the Add-ons drawer; the sidebar lists them only as pins. - await expect(page.locator('.sidebar-nav a.nav-item[href="/app/studio"]')).toHaveCount(await page.getByTestId('pinned-nav').locator('a[href="/app/studio"]').count()) + // Studio and Talk are add-ons: the sidebar lists them only as pins, and only while they are installed. + for (const href of ['/app/studio', '/app/talk']) { + await expect(page.locator(`.sidebar-nav a.nav-item[href="${href}"]`)).toHaveCount(0) + } }) test('Build and Operate sit under a Workspace label', async ({ page }) => { diff --git a/core/http/react-ui/e2e/no-edge-rails.spec.js b/core/http/react-ui/e2e/no-edge-rails.spec.js index f2be34f79..c20baa50e 100644 --- a/core/http/react-ui/e2e/no-edge-rails.spec.js +++ b/core/http/react-ui/e2e/no-edge-rails.spec.js @@ -1,4 +1,5 @@ import { test, expect } from './coverage-fixtures.js' +import { installStudioAddons } from './studio-addon.js' // No coloured strip on the left edge of a tile, card, row or message. A rail // can be drawn four ways, and each is checked from computed style: @@ -16,6 +17,8 @@ const ROUTES = [ ] test('no element on the main routes carries a left accent rail', async ({ page }) => { + // Studio and Talk are add-ons; /app/studio and /app/talk open them. + await installStudioAddons(page) test.setTimeout(ROUTES.length * 8_000) const findings = [] for (const theme of ['light', 'dark']) { diff --git a/core/http/react-ui/e2e/page-header-no-crumb.spec.js b/core/http/react-ui/e2e/page-header-no-crumb.spec.js index 3057f7791..95ddb8d0c 100644 --- a/core/http/react-ui/e2e/page-header-no-crumb.spec.js +++ b/core/http/react-ui/e2e/page-header-no-crumb.spec.js @@ -4,7 +4,6 @@ import { test, expect } from './coverage-fixtures.js' // not repeated above the title as a small crumb: the sidebar and the hub tab // bar already say where the page lives. const PAGES = [ - ['/app/studio', 'Studio'], ['/app/agents', 'Agents'], ['/app/activity', 'Activity'], ] diff --git a/core/http/react-ui/e2e/page-render-smoke.spec.js b/core/http/react-ui/e2e/page-render-smoke.spec.js index 503986c90..2d2ed8314 100644 --- a/core/http/react-ui/e2e/page-render-smoke.spec.js +++ b/core/http/react-ui/e2e/page-render-smoke.spec.js @@ -1,4 +1,5 @@ import { test, expect } from './coverage-fixtures.js' +import { installStudioAddons } from './studio-addon.js' // Render-smoke coverage. Each page is lazy-loaded and runs its full render + // initial effects on mount, so a bare visit captures the bulk of a page's @@ -13,8 +14,6 @@ import { test, expect } from './coverage-fixtures.js' // Asserts the page mounted (its .page-title header is visible) and that it did // not bounce to a gate redirect (/login or back to /app home). const PAGES = [ - ['/app/talk', 'Talk'], - ['/app/studio/threed', '3D Generation'], ['/app/usage', 'Usage'], ['/app/traffic', 'Traffic overview'], ['/app/traffic/models', 'Traffic models'], @@ -23,7 +22,6 @@ const PAGES = [ ['/app/traces', 'Traces'], ['/app/middleware', 'Middleware'], ['/app/account', 'Account'], - ['/app/studio', 'Studio'], ['/app/models', 'Models'], ['/app/operate', 'Operate overview'], ['/app/backends', 'Backends'], @@ -47,10 +45,30 @@ test.describe('Page render smoke', () => { test(`renders ${label} (${path})`, async ({ page }) => { await page.goto(path) // .page-title for the normal header; .empty-state-title for pages that - // render a gated/empty state (e.g. Account when auth is disabled); Talk - // carries its own header. - await expect(page.locator('.page-title, .view-bar__title, .empty-state-title, .dk-empty-title, .talk-hd h1, .op-status__title, .op-runtime h1, .sw-page h1, .tf-title, .st-title').first()).toBeVisible({ timeout: 15_000 }) + // render a gated/empty state (e.g. Account when auth is disabled). + await expect(page.locator('.page-title, .view-bar__title, .empty-state-title, .dk-empty-title, .op-status__title, .op-runtime h1, .sw-page h1, .tf-title, .st-title').first()).toBeVisible({ timeout: 15_000 }) await expect(page).toHaveURL(new RegExp(path.replace(/\//g, '\\/') + '$')) }) } }) + +// Studio and Talk are add-ons: the pages draw inside the add-on shell once the +// built bundles are installed, and the old addresses open them there. +const ADDON_PAGES = [ + ['/app/addons/talk/talk', 'Talk', '.talk-page'], + ['/app/addons/studio/studio', 'Studio', '[data-testid="studio-overview"]'], + ['/app/addons/studio/studio/threed', 'Studio 3D', '.ws-title'], + ['/app/talk', 'Talk (old address)', '.talk-page'], + ['/app/studio/threed', 'Studio 3D (old address)', '.ws-title'], +] + +test.describe('Add-on page render smoke', () => { + for (const [path, label, marker] of ADDON_PAGES) { + test(`renders ${label} (${path})`, async ({ page }) => { + await installStudioAddons(page) + await page.goto(path) + await expect(page.locator(marker).first()).toBeVisible({ timeout: 15_000 }) + await expect(page).toHaveURL(/\/app\/addons\/(studio|talk)\//) + }) + } +}) diff --git a/core/http/react-ui/e2e/studio-addon.js b/core/http/react-ui/e2e/studio-addon.js new file mode 100644 index 000000000..449fb57fb --- /dev/null +++ b/core/http/react-ui/e2e/studio-addon.js @@ -0,0 +1,53 @@ +// Studio and Talk are packaged add-ons. A spec that draws one of them installs +// the real built bundle here: the app list names it, and its module and style +// sheet are served from the build output the way the add-on owner serves them. +// The old /app/studio and /app/talk addresses redirect to the add-on page, so a +// spec can still open those. +import fs from 'node:fs' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { app, mockAddons } from './addons-fixtures.js' + +const addons = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '../../../../addons') +const DIGEST = 'e'.repeat(64) +const TYPES = { js: 'text/javascript', css: 'text/css', png: 'image/png' } +const built = new Map() + +// Builds the bundle once per worker, so a spec never runs against a stale one. +function build(id) { + if (!built.has(id)) built.set(id, import(path.join(addons, id, 'ui/build.mjs')).then(m => m.build())) + return built.get(id) +} + +export function packagedApp(id, over = {}) { + const manifest = JSON.parse(fs.readFileSync(path.join(addons, id, 'manifest.json'), 'utf8')) + const ui = manifest.capabilities.ui + const base = `/api/addons/${id}/assets/${DIGEST}` + return app({ + id, name: id, title: ui.title, description: ui.description, icon: undefined, generation: 1, version: manifest.version, + mainPage: `/app/addons/${id}/${ui.mainPage}`, tools: [], toolCount: 0, + ui: { entry: `${base}/${ui.entry}`, css: ui.css.map(c => `${base}/${c}`), sdk: ui.sdk, pages: ui.pages, navigation: ui.navigation }, + ...over, + }) +} + +// Installs the add-ons and the routes that serve them. `ids` picks which. +// Returns the state of mockAddons. +export async function installStudioAddons(page, { ids = ['studio', 'talk'], ...options } = {}) { + const dirs = await Promise.all(ids.map(build)) + await page.route(/\/api\/addons\/(studio|talk)\/assets\//, route => { + const parts = new URL(route.request().url()).pathname.split('/') + const id = parts[3] + const file = parts.slice(6).join('/') + const dir = dirs[ids.indexOf(id)] + const ext = file.split('.').pop() + if (!dir || !TYPES[ext] || !fs.existsSync(path.join(dir, file))) return route.fulfill({ status: 403, json: { error: { type: 'addon_error', message: 'Addon request denied' } } }) + return route.fulfill({ + status: 200, + contentType: TYPES[ext], + headers: { 'cache-control': 'private, max-age=31536000, immutable', 'x-content-type-options': 'nosniff' }, + body: fs.readFileSync(path.join(dir, file)), + }) + }) + return mockAddons(page, { apps: ids.map(id => packagedApp(id)), ...options }) +} diff --git a/core/http/react-ui/e2e/studio-addons.spec.js b/core/http/react-ui/e2e/studio-addons.spec.js new file mode 100644 index 000000000..64d939576 --- /dev/null +++ b/core/http/react-ui/e2e/studio-addons.spec.js @@ -0,0 +1,110 @@ +import { test, expect } from './coverage-fixtures.js' +import { installStudioAddons } from './studio-addon.js' +import { mockStudio } from './studio-fixtures.js' +import { fakeRealtime } from './chat-fixtures.js' + +// Studio and Talk are packaged add-ons. These specs run the real built bundles +// inside the add-on shell: the pages draw, the old addresses open them, the +// links they hold reach the core pages, and nothing is left when they are not +// installed. + +function watchErrors(page) { + const errors = [] + page.on('pageerror', e => errors.push(String(e))) + page.on('console', m => { if (m.type() === 'error') errors.push(m.text()) }) + return errors +} + +test.describe('Studio add-on', () => { + for (const tab of ['', '/images', '/video', '/threed', '/tts', '/sound', '/transform', '/diarization']) { + test(`the ${tab ? tab.slice(1) : 'overview'} tab draws inside the add-on shell with no script error`, async ({ page }) => { + const errors = watchErrors(page) + await mockStudio(page) + await page.goto(`/app/addons/studio/studio${tab}`) + await expect(page.locator('[data-testid="addon-surface"][data-addon="studio"] .studio-tabs')).toBeVisible({ timeout: 15_000 }) + if (tab) await expect(page.locator(`.studio-tab-active[data-tab="${tab.slice(1)}"]`)).toBeVisible() + else await expect(page.getByTestId('studio-overview')).toBeVisible() + expect(errors).toEqual([]) + }) + } + + test('the shell titles the page once, and the page keeps its own words from the media locale', async ({ page }) => { + await mockStudio(page) + await page.goto('/app/addons/studio/studio') + await expect(page.getByTestId('studio-overview')).toBeVisible({ timeout: 15_000 }) + await expect(page.locator('h1.page-title', { hasText: /^Studio$/ })).toHaveCount(1) + await expect(page.getByText('What do you want to make?')).toBeVisible() + // No raw key is drawn where words belong. + await expect(page.locator('.studio-front').getByText(/studio\.[a-z]+\.[A-Za-z.]+/)).toHaveCount(0) + }) + + test('a model id with a slash and a colon travels as ?model= and reaches the page', async ({ page }) => { + await mockStudio(page) + await page.goto('/app/studio/images/sd-1.5-lcm?size=768x768') + await expect(page).toHaveURL(/\/app\/addons\/studio\/studio\/images\?size=768x768&model=sd-1\.5-lcm$/) + await expect(page.locator('.studio-tab-active[data-tab="images"]')).toBeVisible({ timeout: 15_000 }) + }) + + test('the old Studio addresses say so plainly when the add-on is not installed', async ({ page }) => { + for (const address of ['/app/studio', '/app/studio/tts', '/app/image', '/app/talk']) { + await page.goto(address) + await expect(page.getByTestId('addon-unavailable')).toBeVisible({ timeout: 15_000 }) + await expect(page).toHaveURL(/\/app\/addons\/(studio|talk)\//) + } + await expect(page.locator('.studio-tabs')).toHaveCount(0) + }) + + test('the Studio style sheet reaches the page and nothing outside it', async ({ page }) => { + await mockStudio(page) + await page.goto('/app/addons/studio/studio/images') + await expect(page.locator('.studio-tab-active')).toBeVisible({ timeout: 15_000 }) + const css = await page.evaluate(() => [...document.querySelectorAll('style[data-addon-style="studio"]')].map(el => el.textContent).join('\n')) + expect(css).toContain('[data-addon="studio"]') + expect(css).not.toMatch(/(^|\})\s*\.studio-tab\s*\{/) + // The sidebar is untouched by the sheet. + const sidebarFont = await page.locator('.sidebar-nav .nav-item').first().evaluate(el => getComputedStyle(el).fontFamily) + expect(sidebarFont.length).toBeGreaterThan(0) + }) +}) + +test.describe('Talk add-on', () => { + test('the page draws inside the add-on shell, titled once, with no script error', async ({ page }) => { + const errors = watchErrors(page) + await fakeRealtime(page) + await page.goto('/app/addons/talk/talk') + await expect(page.getByTestId('talk-start')).toBeEnabled({ timeout: 15_000 }) + await expect(page.locator('h1')).toHaveCount(1) + await expect(page.locator('h1')).toHaveText('Talk') + expect(errors).toEqual([]) + }) + + test('a button of the page opens a core page, and the way back to Talk travels with the editor link', async ({ page }) => { + await fakeRealtime(page, { pipelines: false }) + await page.goto('/app/addons/talk/talk') + const card = page.getByTestId('talk-no-pipeline') + await expect(card).toBeVisible({ timeout: 15_000 }) + await card.getByRole('button', { name: 'Create a pipeline model' }).click() + await expect(page).toHaveURL(/\/app\/model-editor\?template=pipeline/) + expect(await page.evaluate(() => window.history.state?.usr)).toEqual({ from: '/app/addons/talk/talk', fromLabel: 'Talk' }) + await page.goBack() + await expect(page.getByTestId('talk-no-pipeline')).toBeVisible() + await page.getByTestId('talk-no-pipeline').getByRole('button', { name: /gallery/i }).click() + await expect(page).toHaveURL(/\/app\/models/) + }) +}) + +test.describe('Without the add-ons', () => { + test('the Add-ons drawer does not list Studio or Talk, and the sidebar has no Create group', async ({ page }) => { + await page.goto('/app/addons') + await expect(page.getByTestId('addon-tile').filter({ hasText: /Studio|Talk/ })).toHaveCount(0) + await expect(page.locator('.sidebar-nav a[href="/app/studio"], .sidebar-nav a[href="/app/talk"]')).toHaveCount(0) + await expect(page.locator('.sidebar-section-title', { hasText: 'Create' })).toHaveCount(0) + }) + + test('installing them lists both in the drawer', async ({ page }) => { + await installStudioAddons(page) + await page.goto('/app/addons') + await expect(page.getByTestId('addon-tile').filter({ hasText: 'Studio' })).toBeVisible({ timeout: 15_000 }) + await expect(page.getByTestId('addon-tile').filter({ hasText: 'Talk' })).toBeVisible() + }) +}) diff --git a/core/http/react-ui/e2e/studio-composer.spec.js b/core/http/react-ui/e2e/studio-composer.spec.js index 9126b4101..f6aa25d62 100644 --- a/core/http/react-ui/e2e/studio-composer.spec.js +++ b/core/http/react-ui/e2e/studio-composer.spec.js @@ -162,10 +162,10 @@ test.describe('Studio composer: keys', () => { await page.goto('/app/studio') await prompt(page).focus() await page.keyboard.press('Control+Enter') - await expect(page).toHaveURL(/\/app\/studio$/) + await expect(page).toHaveURL(/\/app\/addons\/studio\/studio$/) await prompt(page).fill('a brass orrery') await page.keyboard.press('Control+Enter') - await expect(page).toHaveURL(/\/app\/studio\/images\?/) + await expect(page).toHaveURL(/\/app\/addons\/studio\/studio\/images\?/) }) }) @@ -222,7 +222,7 @@ test.describe('Studio composer: hand-off to the workspaces', () => { await page.locator('[data-testid="studio-size"]').selectOption('768x768') await page.locator('[data-testid="studio-count"]').selectOption('3') await generate(page).click() - await expect(page).toHaveURL(/\/app\/studio\/images\?/) + await expect(page).toHaveURL(/\/app\/addons\/studio\/studio\/images\?/) await expect(page.locator('[data-testid="ws-compose"] textarea').first()).toHaveValue('A brass orrery on a walnut desk') await expect(page.locator('[data-testid="ws-size"]').first()).toHaveValue('768x768') await expect(page.locator('[data-testid="ws-count"]').first()).toHaveValue('3') @@ -238,7 +238,7 @@ test.describe('Studio composer: hand-off to the workspaces', () => { await prompt(page).fill('Waves rolling onto black sand, drone shot') await page.locator('[data-testid="studio-size"]').selectOption('1280x720') await generate(page).click() - await expect(page).toHaveURL(/\/app\/studio\/video\?/) + await expect(page).toHaveURL(/\/app\/addons\/studio\/studio\/video\?/) await expect(page.locator('[data-testid="ws-compose"] textarea').first()).toHaveValue('Waves rolling onto black sand, drone shot') await expect(page.locator('[data-testid="ws-size"]').first()).toHaveValue('1280x720') await expect(page.locator('[data-testid="ws-compose"]')).toContainText('wan2.1-t2v-1.3b') @@ -250,7 +250,7 @@ test.describe('Studio composer: hand-off to the workspaces', () => { await pickType(page, 'tts') await prompt(page).fill('Welcome to the harbour tour.') await generate(page).click() - await expect(page).toHaveURL(/\/app\/studio\/tts\?/) + await expect(page).toHaveURL(/\/app\/addons\/studio\/studio\/tts\?/) await expect(page.locator('[data-testid="ws-compose"] textarea').first()).toHaveValue('Welcome to the harbour tour.') await expect(page.locator('[data-testid="ws-compose"]')).toContainText('kokoro-82m') }) @@ -261,7 +261,7 @@ test.describe('Studio composer: hand-off to the workspaces', () => { await pickType(page, 'sound') await prompt(page).fill('Wind through pines, no music') await generate(page).click() - await expect(page).toHaveURL(/\/app\/studio\/sound\?/) + await expect(page).toHaveURL(/\/app\/addons\/studio\/studio\/sound\?/) await expect(page.locator('[data-testid="ws-compose"] textarea').first()).toHaveValue('Wind through pines, no music') }) @@ -273,7 +273,7 @@ test.describe('Studio composer: hand-off to the workspaces', () => { await pickType(page, 'threed') await pickSource(page, /ceramic bowls/) await generate(page).click() - await expect(page).toHaveURL(/\/app\/studio\/threed\?.*from=img-bowls.*edge=to-3d/) + await expect(page).toHaveURL(/\/app\/addons\/studio\/studio\/threed\?.*from=img-bowls.*edge=to-3d/) const note = page.locator('[data-testid="studio-handoff"]') await expect(note).toHaveAttribute('data-status', 'ready') await expect(note).toContainText('ceramic bowls') @@ -291,7 +291,7 @@ test.describe('Studio composer: hand-off to the workspaces', () => { await pickType(page, key) await pickSource(page, /Welcome to the harbour tour/) await generate(page).click() - await expect(page).toHaveURL(new RegExp(`/app/studio/${key}\\?.*edge=${edge}`)) + await expect(page).toHaveURL(new RegExp(`/app/addons/studio/studio/${key}\\?.*edge=${edge}`)) await expect(page.locator('[data-testid="studio-handoff"]')).toHaveAttribute('data-status', 'ready') } // The file reached the Diarization form: its run button is enabled. diff --git a/core/http/react-ui/e2e/studio-fixtures.js b/core/http/react-ui/e2e/studio-fixtures.js index cf7554ae7..f3390cdba 100644 --- a/core/http/react-ui/e2e/studio-fixtures.js +++ b/core/http/react-ui/e2e/studio-fixtures.js @@ -1,5 +1,7 @@ import { expect } from '@playwright/test' -// Shared fixtures for the Studio front page specs: the installed models, a +import { installStudioAddons } from './studio-addon.js' +// Shared fixtures for the Studio front page specs (Studio is an add-on, see +// studio-addon.js): the installed models, a // gallery that offers a model per type, a machine, believable stored history, // and stand-ins for the files the history points at. // @@ -68,6 +70,8 @@ export async function mockStudio(page, { installStatus = 200, } = {}) { const state = { types: [...types], installs: [], capabilityCalls: 0 } + // Studio is an add-on: install the built bundle, then stub what it reads. + await installStudioAddons(page, { ids: ['studio'] }) await page.route('**/api/models/capabilities', route => { state.capabilityCalls += 1 if (capabilitiesStatus !== 200) return route.fulfill({ status: capabilitiesStatus, json: { error: 'down' } }) diff --git a/core/http/react-ui/e2e/studio-lineage.spec.js b/core/http/react-ui/e2e/studio-lineage.spec.js index dc9b18d6d..6c82fb494 100644 --- a/core/http/react-ui/e2e/studio-lineage.spec.js +++ b/core/http/react-ui/e2e/studio-lineage.spec.js @@ -31,7 +31,7 @@ test.describe('Lineage view', () => { await seedHistory(page, sampleHistory()) await page.goto('/app/studio') await page.locator('[data-testid="work-project"] button').first().click() - await expect(page).toHaveURL(/\/app\/studio\?work=vid-push/) + await expect(page).toHaveURL(/\/app\/addons\/studio\/studio\?work=vid-push/) await expect(node(page, 'vid-push')).toHaveAttribute('data-selected', 'true') await page.keyboard.press('Escape') await expect(page.locator('[data-testid="studio-lineage"]')).toHaveCount(0) @@ -115,7 +115,7 @@ test.describe('Lineage view: new take and branch', () => { test('Run as a new take opens the same workspace with the words, model and size and the result as parent', async ({ page }) => { await open(page, 'img-harbour') await page.locator('[data-testid="studio-take"]').click() - await expect(page).toHaveURL(/\/app\/studio\/images\?/) + await expect(page).toHaveURL(/\/app\/addons\/studio\/studio\/images\?/) await expect(page).toHaveURL(/from=img-harbour/) await expect(page).toHaveURL(/edge=take/) await expect(page.locator('[data-testid="ws-compose"] textarea').first()).toHaveValue(/fishing harbour at first light/) @@ -132,7 +132,7 @@ test.describe('Lineage view: new take and branch', () => { await expect(words).toHaveValue('Slow push-in, small natural motion') await words.fill('Gulls crossing the frame') await page.locator('[data-testid="studio-draft-open"]').click() - await expect(page).toHaveURL(/\/app\/studio\/video\?/) + await expect(page).toHaveURL(/\/app\/addons\/studio\/studio\/video\?/) await expect(page).toHaveURL(/from=img-harbour/) await expect(page).toHaveURL(/edge=animate/) await expect(page.locator('[data-testid="ws-compose"] textarea').first()).toHaveValue('Gulls crossing the frame') @@ -184,7 +184,7 @@ test.describe('Lineage view: new take and branch', () => { await expect(page.locator('[data-testid="draft-target-diarize"]')).toBeEnabled() await page.locator('[data-testid="draft-target-diarize"]').click() await page.locator('[data-testid="studio-draft-open"]').click() - await expect(page).toHaveURL(/\/app\/studio\/diarization\?.*from=tts-welcome.*edge=diarize/) + await expect(page).toHaveURL(/\/app\/addons\/studio\/studio\/diarization\?.*from=tts-welcome.*edge=diarize/) await expect(page.locator('[data-testid="studio-handoff"]')).toHaveAttribute('data-status', 'ready') }) }) @@ -222,7 +222,7 @@ test.describe('Lineage view: what a page cannot take yet', () => { test('a transform can be repeated from its stored input', async ({ page }) => { await open(page, 'trf-clean') await page.locator('[data-testid="studio-take"]').click() - await expect(page).toHaveURL(/\/app\/studio\/transform\?.*from=trf-clean.*edge=take/) + await expect(page).toHaveURL(/\/app\/addons\/studio\/studio\/transform\?.*from=trf-clean.*edge=take/) await expect(page.locator('[data-testid="studio-handoff"]')).toHaveAttribute('data-status', 'ready') }) }) diff --git a/core/http/react-ui/e2e/studio-overview.spec.js b/core/http/react-ui/e2e/studio-overview.spec.js index e2f6d346c..da0fd2d7c 100644 --- a/core/http/react-ui/e2e/studio-overview.spec.js +++ b/core/http/react-ui/e2e/studio-overview.spec.js @@ -1,5 +1,6 @@ import { test, expect } from './coverage-fixtures.js' import { composer, pickType } from './studio-fixtures.js' +import { installStudioAddons } from './studio-addon.js' // Studio overview (src/pages/StudioOverview.jsx). // @@ -26,6 +27,8 @@ const SOME_MODELS = { } async function mockCapabilities(page, payload = SOME_MODELS) { + // Studio is an add-on: install the built bundle before the page opens. + await installStudioAddons(page, { ids: ['studio'] }) await page.route('**/api/models/capabilities', route => route.fulfill({ json: payload })) } @@ -111,7 +114,7 @@ test.describe('Studio overview', () => { await page.route('**/api/auth/status', route => route.fulfill({ json: { authEnabled: true, - user: { name: 'someone', role: 'user', permissions: { images: true, video: true, tts: true, sound: true } }, + user: { name: 'someone', role: 'user', permissions: { addons: true, images: true, video: true, tts: true, sound: true } }, }, })) await mockCapabilities(page) @@ -123,6 +126,7 @@ test.describe('Studio overview', () => { test('asks the capabilities endpoint once, not once per modality', async ({ page }) => { let calls = 0 + await installStudioAddons(page, { ids: ['studio'] }) await page.route('**/api/models/capabilities', route => { calls += 1 route.fulfill({ json: SOME_MODELS }) @@ -179,7 +183,7 @@ test.describe('Studio overview', () => { // and must land on the canonical URL rather than a second spelling of it. await mockCapabilities(page) await page.goto('/app/studio?tab=images') - await expect(page).toHaveURL(/\/app\/studio\/images$/) + await expect(page).toHaveURL(/\/app\/addons\/studio\/studio\/images$/) await expect(page.locator('[data-testid="studio-workspace"]')).toBeVisible() }) }) diff --git a/core/http/react-ui/e2e/studio-transform.spec.js b/core/http/react-ui/e2e/studio-transform.spec.js index 8388cccc7..16ef46571 100644 --- a/core/http/react-ui/e2e/studio-transform.spec.js +++ b/core/http/react-ui/e2e/studio-transform.spec.js @@ -1,6 +1,9 @@ import { test, expect } from './coverage-fixtures.js' +import { installStudioAddons } from './studio-addon.js' test.describe('Studio - Transform', () => { + test.beforeEach(async ({ page }) => { await installStudioAddons(page, { ids: ['studio'] }) }) + test('Studio exposes a Transform tab that renders Audio Transform', async ({ page }) => { await page.goto('/app/studio/transform') await expect(page.locator('.studio-tab', { hasText: 'Transform' })).toBeVisible() diff --git a/core/http/react-ui/e2e/studio-workbench.spec.js b/core/http/react-ui/e2e/studio-workbench.spec.js index 987873982..211ae2240 100644 --- a/core/http/react-ui/e2e/studio-workbench.spec.js +++ b/core/http/react-ui/e2e/studio-workbench.spec.js @@ -1,9 +1,12 @@ import { test, expect } from './coverage-fixtures.js' +import { installStudioAddons } from './studio-addon.js' // The generator workbenches (mock 5b/5c): the control column and the record of // what the form actually sent. test.describe('Studio workbench', () => { + test.beforeEach(async ({ page }) => { await installStudioAddons(page, { ids: ['studio'] }) }) + test('the compose card is a flat hairline card, not a shadowed one', async ({ page }) => { await page.goto('/app/studio/images') const card = page.locator('[data-testid="ws-compose"]') diff --git a/core/http/react-ui/e2e/studio-workspace.spec.js b/core/http/react-ui/e2e/studio-workspace.spec.js index 656917ec4..bf5330632 100644 --- a/core/http/react-ui/e2e/studio-workspace.spec.js +++ b/core/http/react-ui/e2e/studio-workspace.spec.js @@ -26,7 +26,7 @@ test.describe('Workspace frame (Images)', () => { await expect(page.locator('[data-testid="studio-workspace"]')).toHaveAttribute('data-type', 'images') await expect(page.locator('.studio-tab')).toHaveCount(8) await page.locator('.studio-tab[data-tab="tts"]').click() - await expect(page).toHaveURL(/\/app\/studio\/tts$/) + await expect(page).toHaveURL(/\/app\/addons\/studio\/studio\/tts$/) await expect(page.locator('[data-testid="studio-workspace"]')).toHaveAttribute('data-type', 'tts') }) @@ -146,7 +146,7 @@ test.describe('Workspace frame (Images)', () => { await expect(page.locator('[data-testid="use-in-to-3d"]')).toBeEnabled() await expect(page.locator('[data-testid="use-in-variation"]')).toBeEnabled() await page.locator('[data-testid="use-in-animate"]').click() - await expect(page).toHaveURL(/\/app\/studio\/video\?.*from=img-forest.*edge=animate/) + await expect(page).toHaveURL(/\/app\/addons\/studio\/studio\/video\?.*from=img-forest.*edge=animate/) await expect(page.locator('[data-testid="studio-handoff"]')).toHaveAttribute('data-status', 'ready') }) @@ -175,7 +175,7 @@ test.describe('Workspace frame (Images)', () => { await page.goto('/app/studio/images') await tile(page).filter({ hasText: 'Misty pine forest' }).locator('.ws-tile__open').click() await page.locator('[data-testid="ws-lineage"]').click() - await expect(page).toHaveURL(/\/app\/studio\?work=img-forest/) + await expect(page).toHaveURL(/\/app\/addons\/studio\/studio\?work=img-forest/) await expect(page.locator('[data-testid="studio-lineage"]')).toBeVisible() }) @@ -187,7 +187,7 @@ test.describe('Workspace frame (Images)', () => { const from = page.locator('[data-testid="ws-from"]') await expect(from).toContainText('animate') await from.getByRole('link').click() - await expect(page).toHaveURL(/\/app\/studio\?work=img-harbour$/) + await expect(page).toHaveURL(/\/app\/addons\/studio\/studio\?work=img-harbour$/) }) test('Re-run with edits loads the take, outlines and lists what changed, and runs the edited values', async ({ page }) => { diff --git a/core/http/react-ui/e2e/talk.spec.js b/core/http/react-ui/e2e/talk.spec.js index 63b048fae..540bbb743 100644 --- a/core/http/react-ui/e2e/talk.spec.js +++ b/core/http/react-ui/e2e/talk.spec.js @@ -280,6 +280,10 @@ test.describe('Talk session settings', () => { await page.getByTestId('talk-settings-button').click() await page.getByTestId('talk-pipeline').getByRole('button', { name: 'Edit pipeline' }).click() await expect(page).toHaveURL(/\/app\/model-editor\/voice-assistant/) + // The editor's Back button reads the way back from the navigation state: the + // add-on page, labelled Talk. + const state = await page.evaluate(() => window.history.state?.usr) + expect(state).toEqual({ from: '/app/addons/talk/talk', fromLabel: 'Talk' }) }) }) diff --git a/core/http/react-ui/e2e/threed-animation.spec.js b/core/http/react-ui/e2e/threed-animation.spec.js index 3a0728c1d..8f2ee2b05 100644 --- a/core/http/react-ui/e2e/threed-animation.spec.js +++ b/core/http/react-ui/e2e/threed-animation.spec.js @@ -1,4 +1,5 @@ import { test, expect } from './coverage-fixtures.js' +import { installStudioAddons } from './studio-addon.js' const animationOperation = { id: 'animate', endpoint: '/3d/animate', output: 'skeleton_animation', @@ -32,6 +33,8 @@ function animationGlb(change = () => {}) { } test.beforeEach(async ({ page }) => { + // Studio is an add-on: the page opens inside it. + await installStudioAddons(page, { ids: ['studio'] }) await page.route('**/api/models/capabilities', route => route.fulfill({ json: { data: [ { id: 'trellis-test', capabilities: ['FLAG_3D'] }, { id: 'kimodo-test', capabilities: ['FLAG_3D_ANIMATION'], three_d_operations: [animationOperation] }, diff --git a/core/http/react-ui/e2e/threed-gen.spec.js b/core/http/react-ui/e2e/threed-gen.spec.js index 934528d15..9fdcbc92f 100644 --- a/core/http/react-ui/e2e/threed-gen.spec.js +++ b/core/http/react-ui/e2e/threed-gen.spec.js @@ -1,4 +1,5 @@ import { test, expect } from './coverage-fixtures.js' +import { installStudioAddons } from './studio-addon.js' // 3D generation page: mock the capabilities + generation endpoints, feed a // real (tiny) Form-B GLB through the parser/viewer, and exercise the @@ -143,6 +144,8 @@ async function pasteImage(page) { test.describe('3D generation', () => { test.beforeEach(async ({ page }) => { + // Studio is an add-on: the page opens inside it. + await installStudioAddons(page, { ids: ['studio'] }) await mockCapabilities(page) await mockGlbDownload(page) }) @@ -353,7 +356,7 @@ test.describe('3D generation', () => { body: JSON.stringify({ authEnabled: true, staticApiKeyRequired: false, - user: { id: 'restricted-user', role: 'user', permissions: { '3d': false } }, + user: { id: 'restricted-user', role: 'user', permissions: { addons: true, '3d': false } }, }), }) })