","status":"passed"|"failed"|"skipped"}
+import { run } from 'node:test'
+import { readdirSync } from 'node:fs'
+import { dirname, join } from 'node:path'
+import { fileURLToPath } from 'node:url'
+
+const here = dirname(fileURLToPath(import.meta.url))
+const names = readdirSync(here).filter(f => f.endsWith('.test.mjs')).sort()
+let failed = false
+const stream = run({ files: names.map(f => join(here, f)), concurrency: 1 })
+stream.on('test:pass', ({ name, skip, todo, nesting }) => {
+ if (nesting > 0) return
+ console.log(JSON.stringify({ case: `unit: ${name}`, status: skip || todo ? 'skipped' : 'passed' }))
+})
+stream.on('test:fail', ({ name, nesting, details }) => {
+ if (nesting > 0) return
+ failed = true
+ console.log(JSON.stringify({ case: `unit: ${name}`, status: 'failed', error: String(details && details.error && details.error.message || 'failed').slice(0, 400) }))
+})
+stream.on('end', () => { process.exit(failed ? 1 : 0) })
+stream.resume()
diff --git a/addons/internal/uibuild/test/fixture-compat/compat/components/Badge.jsx b/addons/internal/uibuild/test/fixture-compat/compat/components/Badge.jsx
new file mode 100644
index 000000000..5b6fc31dd
--- /dev/null
+++ b/addons/internal/uibuild/test/fixture-compat/compat/components/Badge.jsx
@@ -0,0 +1 @@
+export default function Badge() { return badge }
diff --git a/addons/internal/uibuild/test/fixture-compat/compat/modules/jsx-runtime.js b/addons/internal/uibuild/test/fixture-compat/compat/modules/jsx-runtime.js
new file mode 100644
index 000000000..d48a4cf33
--- /dev/null
+++ b/addons/internal/uibuild/test/fixture-compat/compat/modules/jsx-runtime.js
@@ -0,0 +1,4 @@
+export const Fragment = Symbol.for('fragment')
+export function jsx(type, props, key) { return globalThis.__fixtureSdk.React.createElement(type, key === undefined ? props : { ...props, key }) }
+export const jsxs = jsx
+export const jsxDEV = jsx
diff --git a/addons/internal/uibuild/test/fixture-compat/compat/modules/react-dom.js b/addons/internal/uibuild/test/fixture-compat/compat/modules/react-dom.js
new file mode 100644
index 000000000..b1c6ea436
--- /dev/null
+++ b/addons/internal/uibuild/test/fixture-compat/compat/modules/react-dom.js
@@ -0,0 +1 @@
+export default {}
diff --git a/addons/internal/uibuild/test/fixture-compat/compat/modules/react-i18next.js b/addons/internal/uibuild/test/fixture-compat/compat/modules/react-i18next.js
new file mode 100644
index 000000000..b1c6ea436
--- /dev/null
+++ b/addons/internal/uibuild/test/fixture-compat/compat/modules/react-i18next.js
@@ -0,0 +1 @@
+export default {}
diff --git a/addons/internal/uibuild/test/fixture-compat/compat/modules/react-router-dom.js b/addons/internal/uibuild/test/fixture-compat/compat/modules/react-router-dom.js
new file mode 100644
index 000000000..b1c6ea436
--- /dev/null
+++ b/addons/internal/uibuild/test/fixture-compat/compat/modules/react-router-dom.js
@@ -0,0 +1 @@
+export default {}
diff --git a/addons/internal/uibuild/test/fixture-compat/compat/modules/react.js b/addons/internal/uibuild/test/fixture-compat/compat/modules/react.js
new file mode 100644
index 000000000..89ea0cdc1
--- /dev/null
+++ b/addons/internal/uibuild/test/fixture-compat/compat/modules/react.js
@@ -0,0 +1 @@
+export const useState = (v) => globalThis.__fixtureSdk.React.useState(v)
diff --git a/addons/internal/uibuild/test/fixture-compat/host-compat-fixture-runtime.js b/addons/internal/uibuild/test/fixture-compat/host-compat-fixture-runtime.js
new file mode 100644
index 000000000..2510395fc
--- /dev/null
+++ b/addons/internal/uibuild/test/fixture-compat/host-compat-fixture-runtime.js
@@ -0,0 +1 @@
+export const setSdk = (sdk) => { globalThis.__fixtureSdk = sdk }
diff --git a/addons/internal/uibuild/test/fixture-compat/src/Page.jsx b/addons/internal/uibuild/test/fixture-compat/src/Page.jsx
new file mode 100644
index 000000000..b8a3960f3
--- /dev/null
+++ b/addons/internal/uibuild/test/fixture-compat/src/Page.jsx
@@ -0,0 +1,10 @@
+import { useState } from 'react'
+import Badge from './components/Badge'
+import './page.css'
+
+// Uses the automatic JSX runtime, a host module name and a component that is
+// not in src/: the build answers all three from compat/.
+export function Page() {
+ const [n] = useState(3)
+ return compat {n}
+}
diff --git a/addons/internal/uibuild/test/fixture-compat/src/index.jsx b/addons/internal/uibuild/test/fixture-compat/src/index.jsx
new file mode 100644
index 000000000..8d9c84330
--- /dev/null
+++ b/addons/internal/uibuild/test/fixture-compat/src/index.jsx
@@ -0,0 +1,11 @@
+import { useState } from 'react'
+import { setSdk } from '../host-compat-fixture-runtime.js'
+import './page.css'
+
+export async function createAddon(sdk) {
+ setSdk(sdk)
+ const { Page } = await import('./Page.jsx')
+ return { Component: Page, dispose() {} }
+}
+
+export const unused = useState
diff --git a/addons/internal/uibuild/test/fixture-compat/src/page.css b/addons/internal/uibuild/test/fixture-compat/src/page.css
new file mode 100644
index 000000000..7d3a6bba6
--- /dev/null
+++ b/addons/internal/uibuild/test/fixture-compat/src/page.css
@@ -0,0 +1 @@
+.fx { color: red; }
diff --git a/addons/internal/uibuild/test/fixture-compat/src/style.css b/addons/internal/uibuild/test/fixture-compat/src/style.css
new file mode 100644
index 000000000..1cb522a01
--- /dev/null
+++ b/addons/internal/uibuild/test/fixture-compat/src/style.css
@@ -0,0 +1 @@
+/* own */
diff --git a/addons/internal/uibuild/test/uibuild.test.mjs b/addons/internal/uibuild/test/uibuild.test.mjs
index 65528a313..36276d45d 100644
--- a/addons/internal/uibuild/test/uibuild.test.mjs
+++ b/addons/internal/uibuild/test/uibuild.test.mjs
@@ -45,3 +45,34 @@ test('a bare React import makes the build fail', async () => {
test('a relative import of a host UI file makes the build fail', async () => {
await assert.rejects(buildAddonUI({ root: join(here, 'fixture-core'), name: 'x', rgb: [1, 2, 3] }), /host UI file .*useObjectUrl\.js/)
})
+
+test('compat mode answers host module names from the compat directory, collects imported style sheets and writes one file', async () => {
+ const root = join(here, 'fixture-compat')
+ const dist = await buildAddonUI({ root, name: 'fx', rgb: [1, 2, 3], compat: join(root, 'compat') })
+ const js = readFileSync(join(dist, 'ui/fx.js'), 'utf8')
+ assert.doesNotMatch(js, /from\s*['"]react/)
+ assert.doesNotMatch(js, /^\s*import\s/m, 'the bundle is one file with no import')
+ assert.match(js, /export\s*\{[^}]*createAddon/)
+ assert.match(js, /badge/)
+ const css = readFileSync(join(dist, 'ui/fx.css'), 'utf8')
+ assert.match(css, /\/\* own \*\//)
+ assert.match(css, /\.fx \{ color: red; \}/)
+ assert.equal(css.split('.fx {').length - 1, 1, 'a sheet imported twice is added once')
+})
+
+test('compat mode still refuses a host UI file', async () => {
+ await assert.rejects(buildAddonUI({ root: join(here, 'fixture-core'), name: 'x', rgb: [1, 2, 3], compat: join(here, 'fixture-compat/compat') }), /host UI file .*useObjectUrl\.js/)
+})
+
+test('compat mode runs the page: the page code reaches React through the stand-ins', async () => {
+ const { createRequire } = await import('node:module')
+ const { pathToFileURL } = await import('node:url')
+ const require = createRequire(join(here, '../../../../core/http/react-ui/package.json'))
+ const React = require('react')
+ const { renderToString } = require('react-dom/server')
+ const root = join(here, 'fixture-compat')
+ const dist = await buildAddonUI({ root, name: 'fx', rgb: [1, 2, 3], compat: join(root, 'compat') })
+ const mod = await import(pathToFileURL(join(dist, 'ui/fx.js')).href + `?t=${Date.now()}`)
+ const addon = await mod.createAddon({ React })
+ assert.match(renderToString(React.createElement(addon.Component)), /compat 3badge<\/b><\/p>/)
+})
diff --git a/addons/internal/uibuild/uibuild.mjs b/addons/internal/uibuild/uibuild.mjs
index 9ff4f4d04..4f78d58f6 100644
--- a/addons/internal/uibuild/uibuild.mjs
+++ b/addons/internal/uibuild/uibuild.mjs
@@ -6,8 +6,8 @@
// addons/tables/ui/build.mjs, for add-ons that share this builder.
import { createRequire } from 'node:module'
import { fileURLToPath } from 'node:url'
-import { dirname, join, resolve, sep } from 'node:path'
-import { copyFileSync, mkdirSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
+import { dirname, join, relative, resolve, sep } from 'node:path'
+import { copyFileSync, existsSync, mkdirSync, readFileSync, rmSync, statSync, writeFileSync } from 'node:fs'
import { deflateSync } from 'node:zlib'
const here = dirname(fileURLToPath(import.meta.url))
@@ -58,24 +58,84 @@ function hostGuard() {
}
}
+// Compat mode, for an add-on whose source was written against the host's own
+// modules (React hooks, the router, i18next, the shared components). The
+// source keeps those imports; this plugin answers them from a directory of
+// stand-ins that talk to the SDK, so the bundle never holds a host file:
+//
+// - a bare import of react, react-dom, react-router-dom or react-i18next, and
+// the JSX runtime, resolves to compat/modules/.js;
+// - a relative import whose target is not in the add-on's src/ resolves to
+// the file at the same path under compat/ (components/Icon, utils/api ...);
+// - a style sheet the code imports is collected, in order, into the
+// add-on's one style sheet.
+const COMPAT_BARE = {
+ react: 'modules/react.js',
+ 'react/jsx-runtime': 'modules/jsx-runtime.js',
+ 'react/jsx-dev-runtime': 'modules/jsx-runtime.js',
+ 'react-dom': 'modules/react-dom.js',
+ 'react-router-dom': 'modules/react-router-dom.js',
+ 'react-i18next': 'modules/react-i18next.js',
+}
+
+function compatPlugin({ srcRoot, compatSrc, sheets }) {
+ const tails = ['', '.js', '.jsx', '.mjs', `${sep}index.js`, `${sep}index.jsx`]
+ const file = (base) => tails.map((t) => base + t).find((f) => existsSync(f) && statSync(f).isFile())
+ return {
+ name: 'addon-compat',
+ resolveId(source, importer) {
+ if (COMPAT_BARE[source]) return join(compatSrc, COMPAT_BARE[source])
+ if (importer && source.startsWith('.') && importer.startsWith(srcRoot + sep)) {
+ const target = resolve(dirname(importer), source)
+ if (file(target)) return null
+ const rel = relative(srcRoot, target)
+ const hit = !rel.startsWith('..') && file(join(compatSrc, rel))
+ if (hit) return hit
+ }
+ return null
+ },
+ load(id) {
+ if (!id.endsWith('.css')) return null
+ if (!sheets.includes(id)) sheets.push(id)
+ return { code: '', moduleType: 'js' }
+ },
+ }
+}
+
const reactImport = /(?:\bfrom\s*|\bimport\s*\(?\s*|\brequire\s*\(\s*)(['"])(react|react-dom)(?:\/[^'"]*)?\1/
-export async function buildAddonUI({ root, name, rgb }) {
+export async function buildAddonUI({ root, name, rgb, compat }) {
const out = join(root, 'dist')
const { build: bundle } = await import(require.resolve('rolldown'))
rmSync(out, { recursive: true, force: true })
mkdirSync(join(out, 'ui'), { recursive: true })
+ const sheets = []
+ const srcRoot = join(root, 'src')
+ const plugins = [hostGuard()]
+ if (compat) plugins.unshift(compatPlugin({ srcRoot, compatSrc: resolve(compat), sheets }))
await bundle({
input: join(root, 'src/index.jsx'),
// React stays a visible import so the check below can see it. The host
// gives the add-on React through the SDK, so any such import is refused.
- external: [/^react(-dom)?(\/|$)/],
- plugins: [hostGuard()],
- output: { file: join(out, `ui/${name}.js`), format: 'esm', minify: false, sourcemap: false },
- transform: { jsx: { runtime: 'classic', pragma: 'h', pragmaFrag: 'Fragment' } },
+ // In compat mode the plugin answers those imports with stand-ins first.
+ external: compat ? [] : [/^react(-dom)?(\/|$)/],
+ plugins,
+ output: {
+ file: join(out, `ui/${name}.js`), format: 'esm', minify: false, sourcemap: false,
+ // One file: a page loaded on demand is part of it.
+ ...(compat ? { codeSplitting: false } : {}),
+ },
+ transform: { jsx: compat ? { runtime: 'automatic', importSource: 'react' } : { runtime: 'classic', pragma: 'h', pragmaFrag: 'Fragment' } },
logLevel: 'warn',
})
- copyFileSync(join(root, 'src/style.css'), join(out, `ui/${name}.css`))
+ if (compat) {
+ // The add-on's own sheet first, then the sheets its code imported.
+ const own = join(srcRoot, 'style.css')
+ const parts = [existsSync(own) ? readFileSync(own, 'utf8') : '', ...sheets.filter((f) => f !== own).map((f) => readFileSync(f, 'utf8'))]
+ writeFileSync(join(out, `ui/${name}.css`), parts.join('\n'))
+ } else {
+ copyFileSync(join(root, 'src/style.css'), join(out, `ui/${name}.css`))
+ }
writeFileSync(join(out, 'ui/icon.png'), solidIcon(rgb))
const js = readFileSync(join(out, `ui/${name}.js`), 'utf8')
const bad = reactImport.exec(js)
diff --git a/core/http/react-ui/src/addons/AddonRoute.jsx b/core/http/react-ui/src/addons/AddonRoute.jsx
index c3cfa9256..45926adbf 100644
--- a/core/http/react-ui/src/addons/AddonRoute.jsx
+++ b/core/http/react-ui/src/addons/AddonRoute.jsx
@@ -200,14 +200,14 @@ export default function AddonRoute() {
{reloadButton}
)}
-
+
{leaveDialog}
{reloadDialog}
)
}
-function AddonSurface({ id, instance, resolved, frozen = false, t }) {
+function AddonSurface({ id, instance, resolved, search = '', frozen = false, t }) {
const Component = instance.Component
const labels = useMemo(() => ({ title: t('shell.error.title'), text: t('shell.error.text'), retryLabel: t('shell.error.retry') }), [t])
if (!Component) {
@@ -221,7 +221,7 @@ function AddonSurface({ id, instance, resolved, frozen = false, t }) {
data-testid="addon-surface"
>
-
+
)
diff --git a/core/http/react-ui/src/addons/hostServices.js b/core/http/react-ui/src/addons/hostServices.js
index 151daee2d..2b4f4850d 100644
--- a/core/http/react-ui/src/addons/hostServices.js
+++ b/core/http/react-ui/src/addons/hostServices.js
@@ -1,4 +1,5 @@
import * as React from 'react'
+import { createPortal } from 'react-dom'
import Icon from '../components/Icon'
import i18n from '../i18n'
import { addonsApi } from '../utils/addonsClient'
@@ -8,7 +9,16 @@ import {
PageHeader, ConfirmDialog, ErrorWithTraceLink, ModelSelector, MediaInput, WaveformPlayer,
DistributionBars, EmbeddingInspector, TabSwitch,
} from './hostComponents'
+import LoadingSpinner from '../components/LoadingSpinner'
+import Modal from '../components/Modal'
+import Lightbox from '../components/Lightbox'
+import HomeModelPicker from '../components/home/HomeModelPicker'
import { useMediaCapture, audioBufferToWavBlob } from '../hooks/useMediaCapture'
+import { useModels } from '../hooks/useModels'
+import { useResources } from '../hooks/useResources'
+import { useVoiceProfiles } from '../hooks/useVoiceProfiles'
+import { useOperations } from '../hooks/useOperations'
+import { useMCPClient } from '../hooks/useMCPClient'
import useObjectUrl from '../hooks/useObjectUrl'
import { useDelayedAction } from '../hooks/useDelayedAction'
import { basePath } from '../utils/basePath'
@@ -59,8 +69,13 @@ export const host = Object.freeze({
Button, Spinner, EmptyState, Badge, Stack, Text, ArtifactCard,
PageHeader, ConfirmDialog, ErrorWithTraceLink, ModelSelector, MediaInput, WaveformPlayer,
DistributionBars, EmbeddingInspector, TabSwitch,
+ LoadingSpinner, Modal, Lightbox, HomeModelPicker,
},
- hooks: { useMediaCapture, useObjectUrl, useDelayedAction, audioBufferToWavBlob },
+ hooks: {
+ useMediaCapture, useObjectUrl, useDelayedAction, audioBufferToWavBlob,
+ useModels, useResources, useVoiceProfiles, useOperations, useMCPClient,
+ },
+ createPortal,
basePath,
Icon,
theme: { mode: themeMode, subscribe: subscribeTheme },
diff --git a/core/http/react-ui/src/addons/sdk.js b/core/http/react-ui/src/addons/sdk.js
index e4f31f32c..c012dc8ea 100644
--- a/core/http/react-ui/src/addons/sdk.js
+++ b/core/http/react-ui/src/addons/sdk.js
@@ -22,6 +22,10 @@ export const ICON_NAMES = Object.freeze([
'arrow-left', 'arrow-right', 'chart-bar', 'circle-dot', 'clipboard', 'code', 'cube', 'filter-off', 'gender',
'globe', 'headphones', 'help-circle', 'mic', 'play', 'plug-off', 'save', 'shield', 'smile', 'waveform',
'camera', 'stop', 'circle', 'image', 'spinner', 'user-shield', 'mask',
+ // Drawn by the Studio and Talk add-ons.
+ 'boxes', 'compass', 'gauge', 'ghost', 'git-branch', 'images', 'key', 'layers', 'maximize', 'memory', 'music',
+ 'pencil', 'repeat', 'server', 'sliders', 'square-dashed', 'store', 'undo', 'video', 'volume', 'palette', 'phone',
+ 'columns', 'chevron-left',
])
// Theme tokens as stable semantic names. Values are CSS custom properties of
@@ -125,6 +129,31 @@ export function coreRequestUrl(path, basePath = '') {
return `${basePath}${pathname}${search}`
}
+const APP_PATH = /^\/app(?:\/[A-Za-z0-9._~%-]+)*\/?$/
+const APP_QUERY = /^[A-Za-z0-9._~%&=+,:@/-]{0,1024}$/
+
+// appAddress returns an address of the web app (/app/..., with an optional
+// query and hash) or null. Dot segments, other schemes and API routes never pass.
+export function appAddress(input) {
+ if (typeof input !== 'string' || input.length === 0 || input.length > 2048) return null
+ let rest = input
+ let hash = ''
+ const h = rest.indexOf('#')
+ if (h >= 0) { hash = rest.slice(h); rest = rest.slice(0, h) }
+ if (hash && !/^#[A-Za-z0-9._~%-]{0,128}$/.test(hash)) return null
+ let query = ''
+ const q = rest.indexOf('?')
+ if (q >= 0) { query = rest.slice(q); rest = rest.slice(0, q) }
+ if (query && !APP_QUERY.test(query.slice(1))) return null
+ if (!APP_PATH.test(rest)) return null
+ for (const segment of rest.split('/')) {
+ let decoded
+ try { decoded = decodeURIComponent(segment) } catch { return null }
+ if (decoded === '.' || decoded === '..' || decoded.includes('/') || decoded.includes('\\')) return null
+ }
+ return `${rest}${query}${hash}`
+}
+
// An operation identifier is a namespace name to the owner: it starts with a
// letter and holds lower-case letters, digits and hyphens. A bare UUID that
// starts with a digit is refused, so the prefix is part of the identifier.
@@ -189,6 +218,9 @@ export function createHostSDK({ addonId, generation, signal, pages = [], binding
}
// Wrappers are created once per SDK so React sees a stable component type.
+ // React 19 passes `ref` to a function component as a plain prop, so a ref
+ // reaches the wrapped component and a host widget that exposes a handle
+ // (the model picker) stays usable.
const wrap = (Component, fixed) => {
if (!Component) return undefined
const Wrapped = (props) => React.createElement(Component, { ...props, ...fixed })
@@ -216,7 +248,8 @@ export function createHostSDK({ addonId, generation, signal, pages = [], binding
const known = new Set(ICON_NAMES)
const Icon = (props) => {
const name = known.has(props?.name) ? props.name : 'puzzle'
- return React.createElement(host.Icon, { name, size: props?.size, title: props?.title, spin: props?.spin === true, className: props?.className })
+ const style = props?.style && typeof props.style === 'object' ? props.style : undefined
+ return React.createElement(host.Icon, { name, size: props?.size, title: props?.title, spin: props?.spin === true, className: props?.className, style })
}
Icon.displayName = 'AddonIcon'
@@ -370,6 +403,23 @@ export function createHostSDK({ addonId, generation, signal, pages = [], binding
if (!r) throw new AddonSdkError('invalid_path', 'An add-on may link only to its own declared pages')
return `${addonPrefix(addonId)}/${r.path}${r.search ? `?${r.search}` : ''}`
},
+ // Opens a page of the app itself (a core page or a page of another
+ // add-on), for a link that leaves this add-on. Only /app/... addresses
+ // pass: no other origin, scheme or API route.
+ openApp(address, options) {
+ alive()
+ const target = appAddress(address)
+ if (!target) throw new AddonSdkError('invalid_path', 'An add-on may open only addresses under /app')
+ // The only state a page may hand over is the way back: where it was and
+ // what to call it, as short strings (the Model Editor reads these).
+ const from = options?.state?.from
+ const fromLabel = options?.state?.fromLabel
+ const state = typeof from === 'string' && from.startsWith('/app') && from.length <= 2048 && typeof fromLabel === 'string' && fromLabel.length <= 80
+ ? { from, fromLabel }
+ : undefined
+ bindings.navigate?.(target, { replace: options?.replace === true, ...(state ? { state } : {}) })
+ return true
+ },
current: () => {
const c = bindings.current?.() || {}
return { page: String(c.page || ''), path: String(c.path || ''), search: String(c.search || '') }
@@ -382,6 +432,9 @@ export function createHostSDK({ addonId, generation, signal, pages = [], binding
// A write is only ever stored as a proposal; the host confirms it.
propose: (request, options) => callApi('propose', request || {}, options),
}),
+ // Draws children into a node outside the add-on's own tree (a full-screen
+ // sheet). It is the host's portal; the node must be in the page.
+ portal: (children, node) => (host.createPortal && node ? host.createPortal(children, node) : children),
resources,
// Reads that answer with private files. The result is the host's own
// description of each file (identifier, name, media type, size): the
diff --git a/core/http/react-ui/tests/unit/esm.test.mjs b/core/http/react-ui/tests/unit/esm.test.mjs
index 600afd2f5..d0dc59b51 100644
--- a/core/http/react-ui/tests/unit/esm.test.mjs
+++ b/core/http/react-ui/tests/unit/esm.test.mjs
@@ -82,7 +82,7 @@ test('sdk: the object is a frozen allowlist that carries the host React and no h
assert.equal(Object.isFrozen(sdk), true)
assert.deepEqual(Object.keys(sdk).sort(), [
'Icon', 'React', 'addonId', 'api', 'approvals', 'core', 'files', 'generation', 'hooks', 'iconNames', 'isDirty', 'layers', 'locale', 'markDirty', 'navigation',
- 'notify', 'onDispose', 'resources', 'signal', 'theme', 'ui', 'version', 'viewer',
+ 'notify', 'onDispose', 'portal', 'resources', 'signal', 'theme', 'ui', 'version', 'viewer',
])
assert.deepEqual(Object.keys(sdk.api).sort(), ['propose', 'read'])
assert.deepEqual(Object.keys(sdk.ui).sort(), ['Button', 'Text'])
diff --git a/core/http/react-ui/tests/unit/sdkv2.test.mjs b/core/http/react-ui/tests/unit/sdkv2.test.mjs
index 19149b144..e25e18fe2 100644
--- a/core/http/react-ui/tests/unit/sdkv2.test.mjs
+++ b/core/http/react-ui/tests/unit/sdkv2.test.mjs
@@ -2,7 +2,7 @@ import test from 'node:test'
import assert from 'node:assert/strict'
import * as React from 'react'
import { renderToString } from 'react-dom/server'
-import { createHostSDK, coreRequestUrl, ICON_NAMES, SDK_VERSION } from '../../src/addons/sdk.js'
+import { createHostSDK, appAddress, coreRequestUrl, ICON_NAMES, SDK_VERSION } from '../../src/addons/sdk.js'
const C = (tag) => (p) => React.createElement(tag, { 'data-props': JSON.stringify(Object.keys(p).sort()) }, p.children)
const NAMES = ['PageHeader', 'ConfirmDialog', 'ErrorWithTraceLink', 'ModelSelector', 'MediaInput', 'WaveformPlayer', 'DistributionBars', 'EmbeddingInspector', 'TabSwitch']
@@ -125,3 +125,60 @@ test('sdk.core.fetch aborts an in-flight call when the add-on is withdrawn', asy
await assert.rejects(p, /aborted/)
assert.equal(seen.aborted, true)
})
+
+test('appAddress accepts addresses under /app and nothing else', () => {
+ for (const ok of ['/app', '/app/chat', '/app/models/edit/a%20b', '/app/addons/talk/talk?model=x%2Fy&work=1', '/app/studio#top']) {
+ assert.equal(appAddress(ok), ok, ok)
+ }
+ for (const bad of ['', 'app/chat', '//evil.example/app', 'https://evil.example/app', '/api/models', '/app/../api/models', '/app/%2e%2e/x', '/app/a%2Fb/../c', '/app\\evil', '/apps', '/app/x?