feat: adapt to DSH 0.1.2-alpha.1 (Remote gateway + token auth + MarkdownText labels)
Dual-version compatibility: the plugin now works identically on
0.1.0-rc.8 ~ 0.1.1-rc.2 and 0.1.2-alpha.1 (the latter verified against a
source build of the dsh-v0.1.2-alpha.1 tag: mount smoke 14/14 + aggregate
double-mount; 0.1.1-rc.2 re-verified 14/14).
- MarkdownText: alpha.1 renamed the flat optional `codeLabels` prop to a
required nested `labels: { code: {copyLabel, copiedLabel}, footnotes }`;
the old prop alone crashes fence rendering with "reading 'code'". All
four render sites (mermaid / MarkdownHtml / TextEditor / SideChatView)
now go through src/client/markdown-labels.tsx (dual-shape object passed
under both prop names).
- e2e mount lanes speak both host dialects (tests/e2e/host-protocol.ts +
host.ts, unit-locked by tests/e2e-host-protocol.spec.ts):
- one-time-token browser auth: the launch URL grep extends past the
`/?token=` query (truncating at `/` loses the token), goto navigates
the token URL (browser cookie exchange), and stamped navigations
pre-seed the context cookie via addCookies — the exchange 303 drops
sibling query params;
- Remote gateway: dot endpoints first, slash endpoints on 404 fallback,
with args keyed by the controller parameter name (workspace/create ->
{request}, session/list -> {_request}; verified on a live host).
- package.json: drop the @deepseek-ai/dsh-client-runtime peer (package
removed upstream in alpha.1; zero references in the source — the chunk
externals entry stays and is resolution-tolerant).
- scripts/e2e-mount.sh: fix a latent set -u crash in the npx fallback
branch ($DSH_CMD followed by a full-width comma parsed as part of the
variable name).
- CI pin stays at @deepseek-ai/dsh@0.1.1-rc.2 until alpha.1 publishes to
npm (comment updated); README/AGENTS document the new contracts.
- Version lockstep bumped to 0.17.1.
This commit is contained in:
@@ -0,0 +1,201 @@
|
||||
/**
|
||||
* Unit tests for the e2e lanes' host-transport contracts (tests/e2e/
|
||||
* host-protocol.ts + host.ts), locking in the two DSH web dialects the lanes
|
||||
* must speak:
|
||||
*
|
||||
* - 0.1.1-rc.x: bare-origin launch URL, no browser auth, ApiProxy dot
|
||||
* endpoints (`POST /api/workspace.create`, payload = the bare args).
|
||||
* - 0.1.2-alpha.1+ (Remote gateway + one-time-token browser auth):
|
||||
* `/?token=<43 chars>` launch URL (303 → signed cookie), slash endpoints
|
||||
* (`POST /api/workspace/create`, payload must be exactly `{ args }`, the
|
||||
* envelope method equal to the path endpoint; a dot path is no longer
|
||||
* claimed → 404).
|
||||
*
|
||||
* The shapes were transcribed from the deepseek-harness sources at tags
|
||||
* dsh-v0.1.1-rc.2 and dsh-v0.1.2-alpha.1 (packages/client/connection,
|
||||
* packages/api/gateway, packages/bundle/web-app).
|
||||
*/
|
||||
import { beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import type { APIRequestContext } from '@playwright/test'
|
||||
import { parseLaunchUrl, pageUrlWith, rpcAttempts } from './e2e/host-protocol'
|
||||
|
||||
const BARE_URL = 'http://127.0.0.1:4199'
|
||||
const TOKEN_URL = 'http://127.0.0.1:4199/?token=AbCdEf0123456789_-AbCdEf0123456789_-AbCd'
|
||||
|
||||
// host.ts validates DSH_E2E_URL at module load; stub the env BEFORE the
|
||||
// dynamic import so the glue module can load in the unit environment.
|
||||
let host: typeof import('./e2e/host')
|
||||
|
||||
beforeAll(async () => {
|
||||
vi.stubEnv('DSH_E2E_URL', BARE_URL)
|
||||
host = await import('./e2e/host')
|
||||
})
|
||||
|
||||
describe('host-protocol: launch URL parsing', () => {
|
||||
it('splits a bare-origin launch URL (0.1.1-rc.x hosts)', () => {
|
||||
const launch = parseLaunchUrl(BARE_URL)
|
||||
expect(launch.origin).toBe(BARE_URL)
|
||||
expect(launch.pageUrl).toBe(BARE_URL)
|
||||
expect(launch.token).toBeUndefined()
|
||||
})
|
||||
|
||||
it('extracts the one-time token of an authenticated launch URL (0.1.2-alpha.1+)', () => {
|
||||
const launch = parseLaunchUrl(TOKEN_URL)
|
||||
expect(launch.origin).toBe(BARE_URL)
|
||||
expect(launch.pageUrl).toBe(TOKEN_URL)
|
||||
expect(launch.token).toBe('AbCdEf0123456789_-AbCdEf0123456789_-AbCd')
|
||||
})
|
||||
})
|
||||
|
||||
describe('host-protocol: page URL query merge', () => {
|
||||
it('appends stamps to a bare-origin URL', () => {
|
||||
expect(pageUrlWith(BARE_URL, { 'dsh-desktop-mode': 'advanced' }))
|
||||
.toBe(`${BARE_URL}/?dsh-desktop-mode=advanced`)
|
||||
})
|
||||
|
||||
it('merges stamps into a token URL without breaking the token (never append ?…?…)', () => {
|
||||
const merged = pageUrlWith(TOKEN_URL, { 'dsh-desktop-mode': 'advanced', 'dsh-desktop-platform': 'win32' })
|
||||
const parsed = new URL(merged)
|
||||
expect(parsed.searchParams.get('token')).toBe('AbCdEf0123456789_-AbCdEf0123456789_-AbCd')
|
||||
expect(parsed.searchParams.get('dsh-desktop-mode')).toBe('advanced')
|
||||
expect(parsed.searchParams.get('dsh-desktop-platform')).toBe('win32')
|
||||
expect(merged.match(/\?/g)).toHaveLength(1)
|
||||
})
|
||||
})
|
||||
|
||||
describe('host-protocol: dual-dialect RPC attempts', () => {
|
||||
it('orders the 0.1.1-rc.x dot dialect first (published hosts never pay a probe)', () => {
|
||||
const args = { path: '/tmp/w' }
|
||||
const [dot, slash] = rpcAttempts('workspace.create', args)
|
||||
expect(dot).toEqual({
|
||||
protocol: 'dot',
|
||||
path: '/api/workspace.create',
|
||||
method: 'workspace.create',
|
||||
payload: args,
|
||||
})
|
||||
// The dot payload is the BARE args object (same reference — no wrapper).
|
||||
expect(dot!.payload).toBe(args)
|
||||
expect(slash).toEqual({
|
||||
protocol: 'slash',
|
||||
path: '/api/workspace/create',
|
||||
method: 'workspace/create',
|
||||
payload: { args: { request: args } },
|
||||
})
|
||||
})
|
||||
|
||||
it('keys slash args by the parameter name — session/list is literally `_request` (verified on a live 0.1.2-alpha.1 host)', () => {
|
||||
// typert gateway: session/list declares `_request` and rejects `{}` /
|
||||
// `{request:{}}` — the wrapper table must reproduce the exact shape.
|
||||
expect(rpcAttempts('session.list', {})[1]).toEqual({
|
||||
protocol: 'slash',
|
||||
path: '/api/session/list',
|
||||
method: 'session/list',
|
||||
payload: { args: { _request: {} } },
|
||||
})
|
||||
expect(rpcAttempts('session.create', { workspaceId: 'w' })[1]).toEqual({
|
||||
protocol: 'slash',
|
||||
path: '/api/session/create',
|
||||
method: 'session/create',
|
||||
payload: { args: { request: { workspaceId: 'w' } } },
|
||||
})
|
||||
})
|
||||
|
||||
it('fails loudly for methods without a verified slash args key', () => {
|
||||
expect(() => rpcAttempts('settings.describe', {})).toThrow(/no slash-dialect args key/)
|
||||
})
|
||||
})
|
||||
|
||||
/** A scripted APIRequestContext stand-in: records request paths/bodies and
|
||||
* replays the queued responses (the last one repeats). */
|
||||
interface StubResponse { status: number; body: unknown }
|
||||
|
||||
function stubApi(responses: StubResponse[]): APIRequestContext & { paths: string[]; bodies: unknown[] } {
|
||||
const paths: string[] = []
|
||||
const bodies: unknown[] = []
|
||||
let cursor = 0
|
||||
const api = {
|
||||
paths,
|
||||
bodies,
|
||||
post: async (path: string, options?: { data?: unknown }): Promise<{
|
||||
ok: () => boolean
|
||||
status: () => number
|
||||
text: () => Promise<string>
|
||||
}> => {
|
||||
paths.push(path)
|
||||
bodies.push(options?.data)
|
||||
const response = responses[Math.min(cursor, responses.length - 1)]!
|
||||
cursor += 1
|
||||
const bodyText = JSON.stringify(response.body)
|
||||
return {
|
||||
ok: () => response.status >= 200 && response.status < 300,
|
||||
status: () => response.status,
|
||||
text: async () => bodyText,
|
||||
}
|
||||
},
|
||||
}
|
||||
return api as unknown as APIRequestContext & { paths: string[]; bodies: unknown[] }
|
||||
}
|
||||
|
||||
describe('host glue: hostRpc dialect negotiation', () => {
|
||||
beforeEach(() => {
|
||||
host.resetHostRpcForTests()
|
||||
})
|
||||
|
||||
it('uses the dot dialect directly when the host answers it (0.1.1-rc.x)', async () => {
|
||||
const api = stubApi([{ status: 200, body: { type: 'server-response', rpcId: 'x', result: { ok: true, value: { items: [] } } } }])
|
||||
const first = await host.hostRpc<{ items: unknown[] }>(api, 'session.list', {})
|
||||
expect(first.value.items).toEqual([])
|
||||
// Second call must NOT probe again: the resolved dialect is cached.
|
||||
await host.hostRpc(api, 'session.list', {})
|
||||
expect(api.paths).toEqual(['/api/session.list', '/api/session.list'])
|
||||
})
|
||||
|
||||
it('falls back to the slash dialect on 404 and caches it (0.1.2-alpha.1+)', async () => {
|
||||
const api = stubApi([
|
||||
{ status: 404, body: { error: 'not found' } },
|
||||
{ status: 200, body: { type: 'server-response', rpcId: 'x', result: { ok: true, value: { sessionId: 'session-1' } } } },
|
||||
])
|
||||
const seeded = await host.hostRpc<{ sessionId: string }>(api, 'session.create', { workspaceId: 'w' })
|
||||
expect(seeded.value.sessionId).toBe('session-1')
|
||||
expect(api.paths).toEqual(['/api/session.create', '/api/session/create'])
|
||||
// The slash attempt carries the Remote-gateway payload shape: args keyed
|
||||
// by the controller's parameter name (`request` for session/create),
|
||||
// method equal to the path endpoint.
|
||||
const slashEnvelope = api.bodies[1] as { type: string; method: string; payload: Record<string, unknown> }
|
||||
expect(slashEnvelope.type).toBe('client-request')
|
||||
expect(slashEnvelope.method).toBe('session/create')
|
||||
expect(slashEnvelope.payload).toEqual({ args: { request: { workspaceId: 'w' } } })
|
||||
// Cached: the next call goes straight to the slash endpoint.
|
||||
await host.hostRpc(api, 'session.list', {})
|
||||
expect(api.paths[2]).toBe('/api/session/list')
|
||||
})
|
||||
|
||||
it('fails loudly when neither dialect is claimed', async () => {
|
||||
const api = stubApi([{ status: 404, body: {} }])
|
||||
await expect(host.hostRpc(api, 'workspace.create', { path: '/tmp/w' })).rejects.toThrow(/no dialect answered/)
|
||||
})
|
||||
|
||||
it('surfaces an envelope error instead of returning it', async () => {
|
||||
const api = stubApi([{ status: 200, body: { type: 'server-response', rpcId: 'x', result: { ok: false, error: { message: 'boom' } } } }])
|
||||
await expect(host.hostRpc(api, 'workspace.create', { path: '/tmp/w' })).rejects.toThrow(/envelope error/)
|
||||
})
|
||||
})
|
||||
|
||||
describe('host glue: one-time-token cookie exchange', () => {
|
||||
it('follows the token URL with redirect manual and keeps only the cookie pair', async () => {
|
||||
const calls: Array<{ url: string; init: RequestInit }> = []
|
||||
const fetchImpl = (async (url: string | URL, init?: RequestInit) => {
|
||||
calls.push({ url: String(url), init: init ?? {} })
|
||||
return new Response(null, { status: 303, headers: { 'set-cookie': 'dsh-auth-abc=xyz; Path=/; HttpOnly; SameSite=Strict' } })
|
||||
}) as typeof fetch
|
||||
const cookie = await host.exchangeLaunchCookie(TOKEN_URL, fetchImpl)
|
||||
expect(cookie).toBe('dsh-auth-abc=xyz')
|
||||
expect(calls[0]!.url).toBe(TOKEN_URL)
|
||||
expect(calls[0]!.init.redirect).toBe('manual')
|
||||
})
|
||||
|
||||
it('rejects a token exchange without set-cookie (the /api seeding cannot proceed)', async () => {
|
||||
const fetchImpl = (async () => new Response('unauthorized', { status: 401 })) as typeof fetch
|
||||
await expect(host.exchangeLaunchCookie(TOKEN_URL, fetchImpl)).rejects.toThrow(/no set-cookie/)
|
||||
})
|
||||
})
|
||||
@@ -29,12 +29,8 @@
|
||||
import { mkdirSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { test, expect, request, type APIRequestContext, type Locator, type Page } from '@playwright/test'
|
||||
|
||||
const BASE_URL = process.env.DSH_E2E_URL
|
||||
if (!BASE_URL) {
|
||||
throw new Error('DSH_E2E_URL is not set — boot a DSH web instance with the plugin mounted and point this lane at it (see scripts/e2e-mount.sh)')
|
||||
}
|
||||
import { test, expect, type APIRequestContext, type Locator, type Page } from '@playwright/test'
|
||||
import { PAGE_URL, createHostApi, hostRpc } from './host'
|
||||
|
||||
/** This lane's own workspace (distinct from mount.e2e.ts's, lanes run serially
|
||||
* but against the same server — never share seed paths). */
|
||||
@@ -42,28 +38,17 @@ const WORKSPACE_PATH = process.env.DSH_E2E_DRAG_WORKSPACE ?? join(tmpdir(), 'dsh
|
||||
|
||||
let api: APIRequestContext
|
||||
|
||||
/** Seed one workspace + one session through the host's unary RPC surface. */
|
||||
/** Seed one workspace + one session through the host's unary RPC surface
|
||||
* (dual-protocol helper — see ./host-protocol.ts). */
|
||||
async function seedSession(): Promise<void> {
|
||||
mkdirSync(WORKSPACE_PATH, { recursive: true })
|
||||
writeFileSync(join(WORKSPACE_PATH, 'seed.txt'), 'drag lane\n')
|
||||
const workspace = await api.post(`${BASE_URL}/api/workspace.create`, {
|
||||
data: { type: 'client-request', rpcId: 'e2e-drag-workspace', method: 'workspace.create', payload: { path: WORKSPACE_PATH } },
|
||||
})
|
||||
expect(workspace.ok(), `workspace.create: ${workspace.status()} ${await workspace.text()}`).toBe(true)
|
||||
const workspaceBody = (await workspace.json()) as {
|
||||
result: { ok: true; value: { workspace: { workspaceId: string } } } | { ok: false; error: unknown }
|
||||
}
|
||||
expect(workspaceBody.result.ok).toBe(true)
|
||||
const workspaceId = (workspaceBody.result as { value: { workspace: { workspaceId: string } } }).value.workspace.workspaceId
|
||||
|
||||
const session = await api.post(`${BASE_URL}/api/session.create`, {
|
||||
data: { type: 'client-request', rpcId: 'e2e-drag-session', method: 'session.create', payload: { workspaceId } },
|
||||
})
|
||||
expect(session.ok(), `session.create: ${session.status()} ${await session.text()}`).toBe(true)
|
||||
const workspace = await hostRpc<{ workspace: { workspaceId: string } }>(api, 'workspace.create', { path: WORKSPACE_PATH })
|
||||
await hostRpc(api, 'session.create', { workspaceId: workspace.value.workspace.workspaceId })
|
||||
}
|
||||
|
||||
test.beforeAll(async () => {
|
||||
api = await request.newContext({ baseURL: BASE_URL })
|
||||
api = await createHostApi()
|
||||
await seedSession()
|
||||
})
|
||||
|
||||
@@ -81,7 +66,7 @@ interface FrameSample {
|
||||
}
|
||||
|
||||
test('width drag tracks the shell 1:1 with transitions disabled (issue #92)', async ({ page }) => {
|
||||
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' })
|
||||
await page.goto(PAGE_URL, { waitUntil: 'domcontentloaded' })
|
||||
await expect(page.locator('#root > *')).not.toHaveCount(0, { timeout: 90_000 })
|
||||
const sidebar = page.locator('[data-dsh-better-sidebar]')
|
||||
await expect(sidebar).toBeAttached({ timeout: 90_000 })
|
||||
@@ -258,7 +243,7 @@ test('width drag tracks the shell 1:1 with transitions disabled (issue #92)', as
|
||||
})
|
||||
|
||||
test('a very fast width drag still commits the dragged position (no rollback on quick release)', async ({ page }) => {
|
||||
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' })
|
||||
await page.goto(PAGE_URL, { waitUntil: 'domcontentloaded' })
|
||||
await expect(page.locator('#root > *')).not.toHaveCount(0, { timeout: 90_000 })
|
||||
const sidebar = page.locator('[data-dsh-better-sidebar]')
|
||||
await expect(sidebar).toBeAttached({ timeout: 90_000 })
|
||||
@@ -470,7 +455,7 @@ async function dispatchPointerOnStrip(
|
||||
}
|
||||
|
||||
test('an interrupted fast drag (pointercancel → lostpointercapture) keeps the dragged width (issue #247)', async ({ page }) => {
|
||||
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' })
|
||||
await page.goto(PAGE_URL, { waitUntil: 'domcontentloaded' })
|
||||
await expect(page.locator('#root > *')).not.toHaveCount(0, { timeout: 90_000 })
|
||||
const sidebar = page.locator('[data-dsh-better-sidebar]')
|
||||
await expect(sidebar).toBeAttached({ timeout: 90_000 })
|
||||
@@ -505,7 +490,7 @@ test('an interrupted fast drag (pointercancel → lostpointercapture) keeps the
|
||||
})
|
||||
|
||||
test('a capture-lost drag with no usable coordinates keeps the last applied width (issue #247)', async ({ page }) => {
|
||||
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' })
|
||||
await page.goto(PAGE_URL, { waitUntil: 'domcontentloaded' })
|
||||
await expect(page.locator('#root > *')).not.toHaveCount(0, { timeout: 90_000 })
|
||||
const sidebar = page.locator('[data-dsh-better-sidebar]')
|
||||
await expect(sidebar).toBeAttached({ timeout: 90_000 })
|
||||
@@ -540,7 +525,7 @@ test('a capture-lost drag with no usable coordinates keeps the last applied widt
|
||||
drag release ─────────────────────────────────────────────────────── */
|
||||
|
||||
test('bottom panel never flashes full-width after a width drag release (issue #258)', async ({ page }) => {
|
||||
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' })
|
||||
await page.goto(PAGE_URL, { waitUntil: 'domcontentloaded' })
|
||||
await expect(page.locator('#root > *')).not.toHaveCount(0, { timeout: 90_000 })
|
||||
const sidebar = page.locator('[data-dsh-better-sidebar]')
|
||||
await expect(sidebar).toBeAttached({ timeout: 90_000 })
|
||||
@@ -634,7 +619,7 @@ test('the bottom-push anchor resolves through the composite selectors (at least
|
||||
// VERSIONS MAY RENAME THE ATTRIBUTE (issue #208 comment / PR #226), so
|
||||
// the contract is "at least one resolves", and when both resolve they
|
||||
// must hit the SAME element (otherwise the push would land twice).
|
||||
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' })
|
||||
await page.goto(PAGE_URL, { waitUntil: 'domcontentloaded' })
|
||||
await expect(page.locator('[data-dsh-better-sidebar]')).toBeAttached({ timeout: 90_000 })
|
||||
const anchors = await page.evaluate(() => {
|
||||
const a = document.querySelector('#root [data-dsh-frame] > [data-pane="conversation"]')
|
||||
|
||||
@@ -18,12 +18,8 @@
|
||||
import { mkdirSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { test, expect, request, type APIRequestContext, type Page } from '@playwright/test'
|
||||
|
||||
const BASE_URL = process.env.DSH_E2E_URL
|
||||
if (!BASE_URL) {
|
||||
throw new Error('DSH_E2E_URL is not set — boot a DSH web instance with the plugin mounted and point this lane at it (see scripts/e2e-mount.sh)')
|
||||
}
|
||||
import { test, expect, type APIRequestContext, type Page } from '@playwright/test'
|
||||
import { PAGE_URL, createHostApi, hostRpc } from './host'
|
||||
|
||||
const WORKSPACE_PATH = process.env.DSH_E2E_WORKSPACE ?? join(tmpdir(), 'dsh-e2e-float-workspace')
|
||||
|
||||
@@ -33,27 +29,12 @@ let api: APIRequestContext
|
||||
|
||||
async function seedSession(): Promise<void> {
|
||||
mkdirSync(WORKSPACE_PATH, { recursive: true })
|
||||
const workspace = await api.post(`${BASE_URL}/api/workspace.create`, {
|
||||
data: { type: 'client-request', rpcId: 'e2e-float-workspace', method: 'workspace.create', payload: { path: WORKSPACE_PATH } },
|
||||
})
|
||||
expect(workspace.ok(), `workspace.create: ${workspace.status()} ${await workspace.text()}`).toBe(true)
|
||||
const workspaceBody = (await workspace.json()) as {
|
||||
result: { ok: true; value: { workspace: { workspaceId: string } } } | { ok: false; error: unknown }
|
||||
}
|
||||
expect(workspaceBody.result.ok).toBe(true)
|
||||
const workspaceId = (workspaceBody.result as { value: { workspace: { workspaceId: string } } }).value.workspace.workspaceId
|
||||
const session = await api.post(`${BASE_URL}/api/session.create`, {
|
||||
data: { type: 'client-request', rpcId: 'e2e-float-session', method: 'session.create', payload: { workspaceId } },
|
||||
})
|
||||
expect(session.ok(), `session.create: ${session.status()} ${await session.text()}`).toBe(true)
|
||||
const sessionBody = (await session.json()) as {
|
||||
result: { ok: true; value: { sessionId: string } } | { ok: false; error: unknown }
|
||||
}
|
||||
expect(sessionBody.result.ok).toBe(true)
|
||||
const workspace = await hostRpc<{ workspace: { workspaceId: string } }>(api, 'workspace.create', { path: WORKSPACE_PATH })
|
||||
await hostRpc(api, 'session.create', { workspaceId: workspace.value.workspace.workspaceId })
|
||||
}
|
||||
|
||||
test.beforeAll(async () => {
|
||||
api = await request.newContext({ baseURL: BASE_URL })
|
||||
api = await createHostApi()
|
||||
await seedSession()
|
||||
})
|
||||
|
||||
@@ -93,7 +74,7 @@ async function dismissTakeovers(page: Page): Promise<void> {
|
||||
|
||||
/** Load the shell, dismiss onboarding takeovers, and expand the panel.
|
||||
* Returns the sidebar host locator. (An arrow const, not a hoisted function
|
||||
* declaration, so the module-level BASE_URL narrowing carries in.) */
|
||||
* declaration, so the module-level PAGE_URL narrowing carries in.) */
|
||||
const bootExpanded = async (page: Page): Promise<ReturnType<Page['locator']>> => {
|
||||
const pageErrors: string[] = []
|
||||
const consoleErrors: string[] = []
|
||||
@@ -101,7 +82,7 @@ const bootExpanded = async (page: Page): Promise<ReturnType<Page['locator']>> =>
|
||||
page.on('console', (message) => {
|
||||
if (message.type() === 'error') consoleErrors.push(message.text())
|
||||
})
|
||||
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' })
|
||||
await page.goto(PAGE_URL, { waitUntil: 'domcontentloaded' })
|
||||
await expect(page.locator('#root > *')).not.toHaveCount(0, { timeout: 90_000 })
|
||||
const sidebar = page.locator('[data-dsh-better-sidebar]')
|
||||
await expect(sidebar).toBeAttached({ timeout: 90_000 })
|
||||
|
||||
@@ -0,0 +1,104 @@
|
||||
/**
|
||||
* Pure host-transport contracts for the e2e lanes, spanning the DSH
|
||||
* 0.1.1-rc.x → 0.1.2-alpha.1 breaking split (Remote gateway + one-time-token
|
||||
* browser auth). No Playwright imports here — these shapes are unit-testable
|
||||
* (tests/e2e-host-protocol.spec.ts); ./host.ts wires them to a request
|
||||
* context.
|
||||
*
|
||||
* What changed in 0.1.2-alpha.1 (deepseek-harness tag dsh-v0.1.2-alpha.1):
|
||||
*
|
||||
* - `dsh web` prints an AUTHENTICATED launch URL — `dsh web:
|
||||
* http://127.0.0.1:<port>/?token=<43 base64url chars>`. Navigating it
|
||||
* exchanges the token for a signed cookie (HttpOnly, SameSite=Strict) that
|
||||
* every `/api` request must then carry; the clean URL answers 401. Older
|
||||
* hosts print a bare origin and have no browser auth.
|
||||
* - The legacy ApiProxy dot-method endpoints (`POST /api/workspace.create`
|
||||
* with `payload: <args>`) were replaced by the Remote gateway's slash
|
||||
* endpoints — `POST /api/workspace/create` with `payload: { args }` whose
|
||||
* args object is keyed by the controller's TypeScript PARAMETER name
|
||||
* (`workspace/create` → `{ request: {...} }`; `session/list`'s unused
|
||||
* parameter is literally `_request` and is not omissible); a dot path is
|
||||
* no longer claimed (404). The `{type:'client-request', rpcId, method,
|
||||
* payload}` / `{type:'server-response', rpcId, result}` envelopes are
|
||||
* unchanged, and the plugin's own `/sidebar/*` routes stay public (the
|
||||
* webserver carrier owns no authentication — only `/api`, the index HTML,
|
||||
* and the remote mux upgrade sit behind the browser auth).
|
||||
*/
|
||||
|
||||
/** The pieces of a `dsh web` launch URL the lanes need. */
|
||||
export interface LaunchUrl {
|
||||
/** Scheme + host + port — the base for URL construction (a token URL would
|
||||
* corrupt path concatenation). */
|
||||
origin: string
|
||||
/** The URL as printed (token query included on 0.1.2-alpha.1+); the correct
|
||||
* page.goto target — navigating it performs the token→cookie exchange. */
|
||||
pageUrl: string
|
||||
/** The one-time launch token, when the host printed one. */
|
||||
token: string | undefined
|
||||
}
|
||||
|
||||
/** Split a `dsh web` launch URL (bare origin on 0.1.1-rc.x, `/?token=` on
|
||||
* 0.1.2-alpha.1+) into the pieces the lanes address. */
|
||||
export function parseLaunchUrl(raw: string): LaunchUrl {
|
||||
const url = new URL(raw)
|
||||
return {
|
||||
origin: url.origin,
|
||||
pageUrl: raw,
|
||||
token: url.searchParams.get('token') ?? undefined,
|
||||
}
|
||||
}
|
||||
|
||||
/** Page navigation URL with extra query stamps (e.g. desktop-shell URL
|
||||
* parameters) merged in — never naively append `?...` to a token URL (a
|
||||
* double `?` breaks both params). The token survives the merge. */
|
||||
export function pageUrlWith(raw: string, extra: Record<string, string>): string {
|
||||
const url = new URL(raw)
|
||||
for (const [key, value] of Object.entries(extra)) url.searchParams.set(key, value)
|
||||
return url.toString()
|
||||
}
|
||||
|
||||
/** One host-RPC protocol attempt. */
|
||||
export interface RpcAttempt {
|
||||
/** Which host dialect this attempt speaks. */
|
||||
protocol: 'dot' | 'slash'
|
||||
/** Request path, relative to the origin, starting with `/api/`. */
|
||||
path: string
|
||||
/** Envelope `method` string — must equal the endpoint the path selects. */
|
||||
method: string
|
||||
/** Envelope `payload` — the bare args object on dot hosts, `{ args }` on
|
||||
* slash hosts (the gateway rejects any other payload shape). */
|
||||
payload: Record<string, unknown>
|
||||
}
|
||||
|
||||
/** The slash-dialect args key per host method: the 0.1.2-alpha.1 Remote
|
||||
* controllers key the args object by the TypeScript PARAMETER name, not by
|
||||
* the endpoint's own naming — workspace/create and session/create declare a
|
||||
* single `request` parameter, while session/list's unused parameter is
|
||||
* literally named `_request` (and is NOT omissible: the typert gateway
|
||||
* rejects `{}` and `{request:{}}` alike). Verified against a live
|
||||
* dsh-v0.1.2-alpha.1 host: every other shape fails with `args fields do not
|
||||
* match the descriptor`. */
|
||||
const SLASH_ARGS_KEY: Record<string, string> = {
|
||||
'workspace.create': 'request',
|
||||
'session.create': 'request',
|
||||
'session.list': '_request',
|
||||
}
|
||||
|
||||
/** Build the two dialects of one host RPC (method in dot form, e.g.
|
||||
* `'workspace.create'`): the 0.1.1-rc.x dot endpoint first — the dialect
|
||||
* every currently-published host speaks, so the deployed lanes never pay a
|
||||
* probe round-trip — then the 0.1.2-alpha.1+ slash endpoint as the 404
|
||||
* fallback. Throws for methods without a known slash args key: the wrapper
|
||||
* is per-parameter, so an unverified shape must fail loudly at build time
|
||||
* instead of as a confusing gateway error mid-lane. */
|
||||
export function rpcAttempts(method: string, args: Record<string, unknown>): RpcAttempt[] {
|
||||
const slashKey = SLASH_ARGS_KEY[method]
|
||||
if (slashKey === undefined) {
|
||||
throw new Error(`host-protocol: no slash-dialect args key for ${JSON.stringify(method)} — verify the parameter name on the host and extend SLASH_ARGS_KEY`)
|
||||
}
|
||||
const slash = method.split('.').join('/')
|
||||
return [
|
||||
{ protocol: 'dot', path: `/api/${method}`, method, payload: args },
|
||||
{ protocol: 'slash', path: `/api/${slash}`, method: slash, payload: { args: { [slashKey]: args } } },
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
/**
|
||||
* Host glue for the e2e lanes: page/asset URL construction, the authenticated
|
||||
* request context, and the dual-protocol host RPC. The cross-version shapes
|
||||
* (bare origin vs one-time-token URL, dot vs slash `/api` endpoints) live in
|
||||
* ./host-protocol.ts; this module only wires them to Playwright.
|
||||
*
|
||||
* Every lane consumes the same contract regardless of host version:
|
||||
* - `PAGE_URL` / `pageUrl({...})` for page.goto (the token URL performs the
|
||||
* browser's token→cookie exchange on 0.1.2-alpha.1+; a bare origin is a
|
||||
* no-op on older hosts),
|
||||
* - `sidebarApi(path)` for the plugin's own public routes,
|
||||
* - `createHostApi()` for an APIRequestContext that carries the auth cookie
|
||||
* when the host printed a token,
|
||||
* - `hostRpc(api, 'workspace.create', {...})` for the host's unary RPC —
|
||||
* dot endpoint first, slash endpoint on 404 fallback (see host-protocol).
|
||||
*/
|
||||
import { request, type APIRequestContext, type Page } from '@playwright/test'
|
||||
import { parseLaunchUrl, pageUrlWith, rpcAttempts } from './host-protocol'
|
||||
|
||||
const envUrl = process.env.DSH_E2E_URL
|
||||
if (!envUrl) {
|
||||
throw new Error('DSH_E2E_URL is not set — boot a DSH web instance with the plugin mounted and point this lane at it (see scripts/e2e-mount.sh)')
|
||||
}
|
||||
// Re-bind with an explicit string type: closure bodies below do not inherit
|
||||
// the guard's narrowing of envUrl.
|
||||
const RAW_URL: string = envUrl
|
||||
|
||||
const LAUNCH = parseLaunchUrl(RAW_URL)
|
||||
|
||||
/** Origin for URL construction (a token URL would corrupt path joins). */
|
||||
export const ORIGIN = LAUNCH.origin
|
||||
|
||||
/** page.goto target: the launch URL as printed (token included on
|
||||
* 0.1.2-alpha.1+ — navigating it exchanges the token for the cookie). */
|
||||
export const PAGE_URL = LAUNCH.pageUrl
|
||||
|
||||
/** page.goto target with extra query stamps merged (token preserved). */
|
||||
export function pageUrl(extra: Record<string, string>): string {
|
||||
return pageUrlWith(RAW_URL, extra)
|
||||
}
|
||||
|
||||
/** Absolute URL for the plugin's own (public, unauthenticated) API route. */
|
||||
export function sidebarApi(path: string): string {
|
||||
return `${ORIGIN}/sidebar/api/${path}`
|
||||
}
|
||||
|
||||
/** Exchange the one-time launch token for the auth-cookie header pair: the
|
||||
* token URL answers 303 with Set-Cookie (following the redirect would lose
|
||||
* it, hence `redirect: 'manual'`). Pure aside from the injected fetch —
|
||||
* unit tests stub it (tests/e2e-host-protocol.spec.ts). */
|
||||
export async function exchangeLaunchCookie(
|
||||
launchUrl: string,
|
||||
fetchImpl: typeof fetch = fetch,
|
||||
): Promise<string> {
|
||||
const res = await fetchImpl(launchUrl, { redirect: 'manual' })
|
||||
const setCookie = res.headers.get('set-cookie')
|
||||
if (setCookie === null) {
|
||||
throw new Error(`token exchange failed: HTTP ${res.status} carried no set-cookie — cannot authenticate /api seeding`)
|
||||
}
|
||||
return setCookie.split(';', 1)[0] ?? ''
|
||||
}
|
||||
|
||||
/** The `name=value` pair of the auth cookie, once per process. */
|
||||
let cookieHeader: string | undefined
|
||||
|
||||
/** page.goto through the host auth. A plain navigation goes to PAGE_URL (the
|
||||
* token URL performs the browser's exchange on 0.1.2-alpha.1+), but a
|
||||
* STAMPED navigation cannot: the exchange answers `303 → /` and silently
|
||||
* DROPS every sibling query param (the mount lane caught the desktop-shell
|
||||
* stamps vanishing this way), so pre-seed the browser context with the
|
||||
* exchanged cookie and navigate the stamped URL directly. Playwright gives
|
||||
* every test a fresh context, so the seeding is per-call and idempotent. */
|
||||
export async function gotoPage(page: Page, extra: Record<string, string> = {}): Promise<void> {
|
||||
if (LAUNCH.token !== undefined && Object.keys(extra).length > 0) {
|
||||
if (cookieHeader === undefined) cookieHeader = await exchangeLaunchCookie(RAW_URL)
|
||||
const eq = cookieHeader.indexOf('=')
|
||||
await page.context().addCookies([{
|
||||
name: cookieHeader.slice(0, eq),
|
||||
value: cookieHeader.slice(eq + 1),
|
||||
url: ORIGIN,
|
||||
}])
|
||||
await page.goto(pageUrlWith(ORIGIN, extra), { waitUntil: 'domcontentloaded' })
|
||||
return
|
||||
}
|
||||
await page.goto(pageUrl(extra), { waitUntil: 'domcontentloaded' })
|
||||
}
|
||||
|
||||
/** Authenticated request context for host RPC seeding (cookie attached when
|
||||
* the launch URL carried a one-time token; plain on older hosts). */
|
||||
export async function createHostApi(): Promise<APIRequestContext> {
|
||||
if (cookieHeader === undefined && LAUNCH.token !== undefined) {
|
||||
cookieHeader = await exchangeLaunchCookie(RAW_URL)
|
||||
}
|
||||
return request.newContext({
|
||||
baseURL: ORIGIN,
|
||||
extraHTTPHeaders: cookieHeader === undefined ? {} : { cookie: cookieHeader },
|
||||
})
|
||||
}
|
||||
|
||||
/** The unwrapped `result` of a successful host RPC. */
|
||||
export interface HostRpcOk<T> {
|
||||
ok: true
|
||||
value: T
|
||||
}
|
||||
|
||||
/** Resolved dialect for hostRpc — cached after the first call so only the
|
||||
* first RPC ever pays the 404 probe (dot-first: published hosts answer it
|
||||
* directly; 0.1.2-alpha.1+ 404s the dot path and answers the slash one). */
|
||||
let protocol: 'dot' | 'slash' | undefined
|
||||
|
||||
/** Test hook: forget the resolved dialect between unit-test cases. */
|
||||
export function resetHostRpcForTests(): void {
|
||||
protocol = undefined
|
||||
}
|
||||
|
||||
let rpcCounter = 0
|
||||
|
||||
/** Call a host unary RPC (`'workspace.create'`, `'session.create'`, …) with
|
||||
* the args object both dialects accept. Throws with the response body when
|
||||
* the HTTP call or the envelope result fails — the lanes' seeding treats any
|
||||
* failure as fatal, so the error text carries everything a report needs. */
|
||||
export async function hostRpc<T = unknown>(
|
||||
api: APIRequestContext,
|
||||
method: string,
|
||||
args: Record<string, unknown> = {},
|
||||
): Promise<HostRpcOk<T>> {
|
||||
const attempts = rpcAttempts(method, args)
|
||||
const ordered = protocol === 'slash' ? [attempts[1]!, attempts[0]!] : attempts
|
||||
rpcCounter += 1
|
||||
const rpcId = `e2e-${method}-${rpcCounter}`
|
||||
for (const attempt of ordered) {
|
||||
const res = await api.post(attempt.path, {
|
||||
data: { type: 'client-request', rpcId, method: attempt.method, payload: attempt.payload },
|
||||
})
|
||||
const bodyText = await res.text()
|
||||
if (res.status() === 404 && protocol === undefined) continue // endpoint not claimed on this host → other dialect
|
||||
if (!res.ok()) {
|
||||
throw new Error(`hostRpc ${method} [${attempt.protocol}] HTTP ${res.status()}: ${bodyText.slice(0, 400)}`)
|
||||
}
|
||||
let envelope: { type?: string; result?: { ok: true; value: T } | { ok: false; error: unknown } }
|
||||
try {
|
||||
envelope = JSON.parse(bodyText) as typeof envelope
|
||||
} catch {
|
||||
throw new Error(`hostRpc ${method} [${attempt.protocol}]: non-JSON response: ${bodyText.slice(0, 400)}`)
|
||||
}
|
||||
const result = envelope.result
|
||||
if (result === undefined || result.ok !== true) {
|
||||
throw new Error(`hostRpc ${method} [${attempt.protocol}] envelope error: ${bodyText.slice(0, 400)}`)
|
||||
}
|
||||
protocol = attempt.protocol
|
||||
return result
|
||||
}
|
||||
throw new Error(`hostRpc ${method}: no dialect answered (dot and slash both 404) — is this a DSH web host?`)
|
||||
}
|
||||
+26
-49
@@ -29,12 +29,8 @@
|
||||
import { mkdirSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { test, expect, request, type APIRequestContext } from '@playwright/test'
|
||||
|
||||
const BASE_URL = process.env.DSH_E2E_URL
|
||||
if (!BASE_URL) {
|
||||
throw new Error('DSH_E2E_URL is not set — boot a DSH web instance with the plugin mounted and point this lane at it (see scripts/e2e-mount.sh)')
|
||||
}
|
||||
import { test, expect, type APIRequestContext } from '@playwright/test'
|
||||
import { PAGE_URL, createHostApi, gotoPage, hostRpc, sidebarApi } from './host'
|
||||
|
||||
/** Workspace the sidebar renders against (created by the lane's seeding). */
|
||||
const WORKSPACE_PATH = process.env.DSH_E2E_WORKSPACE ?? join(tmpdir(), 'dsh-e2e-workspace')
|
||||
@@ -127,29 +123,17 @@ async function seedSession(): Promise<void> {
|
||||
'[def]: https://example.com/def',
|
||||
'',
|
||||
].join('\n'))
|
||||
const workspace = await api.post(`${BASE_URL}/api/workspace.create`, {
|
||||
data: { type: 'client-request', rpcId: 'e2e-workspace', method: 'workspace.create', payload: { path: WORKSPACE_PATH } },
|
||||
})
|
||||
expect(workspace.ok(), `workspace.create: ${workspace.status()} ${await workspace.text()}`).toBe(true)
|
||||
const workspaceBody = (await workspace.json()) as {
|
||||
result: { ok: true; value: { workspace: { workspaceId: string } } } | { ok: false; error: unknown }
|
||||
}
|
||||
expect(workspaceBody.result.ok).toBe(true)
|
||||
const workspaceId = (workspaceBody.result as { value: { workspace: { workspaceId: string } } }).value.workspace.workspaceId
|
||||
|
||||
const session = await api.post(`${BASE_URL}/api/session.create`, {
|
||||
data: { type: 'client-request', rpcId: 'e2e-session', method: 'session.create', payload: { workspaceId } },
|
||||
})
|
||||
expect(session.ok(), `session.create: ${session.status()} ${await session.text()}`).toBe(true)
|
||||
const sessionBody = (await session.json()) as {
|
||||
result: { ok: true; value: { sessionId: string } } | { ok: false; error: unknown }
|
||||
}
|
||||
expect(sessionBody.result.ok).toBe(true)
|
||||
seededSessionId = (sessionBody.result as { value: { sessionId: string } }).value.sessionId
|
||||
// Seeded through the lanes' dual-protocol RPC helper (./host): 0.1.1-rc.x
|
||||
// dot endpoints first, 0.1.2-alpha.1+ slash endpoints on 404 fallback, and
|
||||
// the request context carries the auth cookie when the launch URL had a
|
||||
// one-time token.
|
||||
const workspace = await hostRpc<{ workspace: { workspaceId: string } }>(api, 'workspace.create', { path: WORKSPACE_PATH })
|
||||
const session = await hostRpc<{ sessionId: string }>(api, 'session.create', { workspaceId: workspace.value.workspace.workspaceId })
|
||||
seededSessionId = session.value.sessionId
|
||||
}
|
||||
|
||||
test.beforeAll(async () => {
|
||||
api = await request.newContext({ baseURL: BASE_URL })
|
||||
api = await createHostApi()
|
||||
await seedSession()
|
||||
})
|
||||
|
||||
@@ -176,7 +160,7 @@ test('plugin mounts into the DSH shell and survives a built-in tab sweep', async
|
||||
(response) => response.url().includes('/sidebar/bundle/editor.js'),
|
||||
{ timeout: 120_000 },
|
||||
)
|
||||
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' })
|
||||
await page.goto(PAGE_URL, { waitUntil: 'domcontentloaded' })
|
||||
await expect(page.locator('#root > *')).not.toHaveCount(0, { timeout: 90_000 })
|
||||
const sidebar = page.locator('[data-dsh-better-sidebar]')
|
||||
await expect(sidebar).toBeAttached({ timeout: 90_000 })
|
||||
@@ -310,7 +294,7 @@ test('plugin mounts into the DSH shell and survives a built-in tab sweep', async
|
||||
// (keyless boot has no model route), but admission + creation + the wire
|
||||
// envelope must all succeed — this is the deepest functional proof the
|
||||
// mount lane can make without a provider.
|
||||
const start = await api.post(`${BASE_URL}/sidebar/api/sidechat.start`, {
|
||||
const start = await api.post(sidebarApi('sidechat.start'), {
|
||||
data: { sessionId: seededSessionId, question: 'mount lane smoke' },
|
||||
})
|
||||
expect(start.ok(), `sidechat.start: ${start.status()} ${await start.text()}`).toBe(true)
|
||||
@@ -325,21 +309,14 @@ test('plugin mounts into the DSH shell and survives a built-in tab sweep', async
|
||||
// The child must be a REAL session in the host's store (provider-free
|
||||
// proof of the custom-seed creation; the boundary message itself only
|
||||
// becomes durable when a turn claims it, which needs a model route).
|
||||
const list = await api.post(`${BASE_URL}/api/session.list`, {
|
||||
data: { type: 'client-request', rpcId: 'e2e-sidechat-list', method: 'session.list', payload: {} },
|
||||
})
|
||||
expect(list.ok(), `session.list: ${list.status()} ${await list.text()}`).toBe(true)
|
||||
const listBody = (await list.json()) as {
|
||||
result: { ok: true; value: { items: Array<{ sessionId: string }> } } | { ok: false; error: unknown }
|
||||
}
|
||||
expect(listBody.result.ok, `session.list envelope: ${JSON.stringify(listBody)}`).toBe(true)
|
||||
const listedItems = (listBody.result as { value: { items: Array<{ sessionId: string }> } }).value.items
|
||||
const list = await hostRpc<{ items: Array<{ sessionId: string }> }>(api, 'session.list', {})
|
||||
const listedItems = list.value.items
|
||||
expect(
|
||||
listedItems.some(item => item.sessionId === childId),
|
||||
'the thread child must appear in the host session list',
|
||||
).toBe(true)
|
||||
for (const method of ['sidechat.prompt', 'sidechat.cancel', 'sidechat.dispose']) {
|
||||
const response = await api.post(`${BASE_URL}/sidebar/api/${method}`, {
|
||||
const response = await api.post(sidebarApi(method), {
|
||||
data: method === 'sidechat.prompt' ? { childId, text: 'follow-up' } : { childId },
|
||||
})
|
||||
expect(response.ok(), `${method}: ${response.status()} ${await response.text()}`).toBe(true)
|
||||
@@ -348,14 +325,14 @@ test('plugin mounts into the DSH shell and survives a built-in tab sweep', async
|
||||
// The Codex-style immediate-create flow: a blank question creates an
|
||||
// EMPTY thread (no prompt admitted), sidechat.info reports the live
|
||||
// agent, and the first prompt delivers the boundary host-side.
|
||||
const empty = await api.post(`${BASE_URL}/sidebar/api/sidechat.start`, {
|
||||
const empty = await api.post(sidebarApi('sidechat.start'), {
|
||||
data: { sessionId: seededSessionId, question: '' },
|
||||
})
|
||||
expect(empty.ok(), `sidechat.start (empty): ${empty.status()} ${await empty.text()}`).toBe(true)
|
||||
const emptyBody = (await empty.json()) as { ok: boolean; value?: { childId?: string } }
|
||||
const emptyChildId = emptyBody.value?.childId
|
||||
expect(emptyChildId, 'immediate create must return a child session id').toMatch(/^session-/)
|
||||
const info = await api.post(`${BASE_URL}/sidebar/api/sidechat.info`, {
|
||||
const info = await api.post(sidebarApi('sidechat.info'), {
|
||||
data: { childId: emptyChildId },
|
||||
})
|
||||
expect(info.ok(), `sidechat.info: ${info.status()} ${await info.text()}`).toBe(true)
|
||||
@@ -363,7 +340,7 @@ test('plugin mounts into the DSH shell and survives a built-in tab sweep', async
|
||||
expect(infoBody.ok, `sidechat.info envelope: ${JSON.stringify(infoBody)}`).toBe(true)
|
||||
expect(infoBody.value?.live, 'the fresh thread must have a live agent').toBe(true)
|
||||
for (const method of ['sidechat.prompt', 'sidechat.dispose']) {
|
||||
const response = await api.post(`${BASE_URL}/sidebar/api/${method}`, {
|
||||
const response = await api.post(sidebarApi(method), {
|
||||
data: method === 'sidechat.prompt' ? { childId: emptyChildId, text: 'first message' } : { childId: emptyChildId },
|
||||
})
|
||||
expect(response.ok(), `${method} (immediate thread): ${response.status()} ${await response.text()}`).toBe(true)
|
||||
@@ -559,7 +536,7 @@ test('conservative auto: URL stamps alone never modify the layout; plugin chrome
|
||||
// Window Controls Overlay API the layout must stay untouched (plain-web
|
||||
// semantics) — the strip/body attribute appear only for real standard
|
||||
// geometry (see the WCO scenario below) or an opt-in preset.
|
||||
await page.goto(`${BASE_URL}?dsh-desktop-mode=advanced&dsh-desktop-platform=win32`, { waitUntil: 'domcontentloaded' })
|
||||
await gotoPage(page, { 'dsh-desktop-mode': 'advanced', 'dsh-desktop-platform': 'win32' })
|
||||
await expect(page.locator('[data-dsh-better-sidebar]')).toBeAttached({ timeout: 90_000 })
|
||||
await expect(
|
||||
page.locator('body[data-dsh-title-bar-compat]'),
|
||||
@@ -607,7 +584,7 @@ test('standard WCO geometry drives the strip reactively (issue #257)', async ({
|
||||
},
|
||||
}
|
||||
})
|
||||
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' })
|
||||
await page.goto(PAGE_URL, { waitUntil: 'domcontentloaded' })
|
||||
await expect(page.locator('[data-dsh-better-sidebar]')).toBeAttached({ timeout: 90_000 })
|
||||
// Real reported height (36px, not a hardcoded 32) drives the strip.
|
||||
await expect(page.locator('body[data-dsh-title-bar-compat]')).toBeAttached({ timeout: 90_000 })
|
||||
@@ -629,12 +606,12 @@ test('opt-in shell preset applies its strip when WCO is absent (data-driven, man
|
||||
// The anywhere-labs DSH Desktop preset (shell-presets.ts) is OPT-IN: under
|
||||
// the preset scheme the win32 advanced stamp resolves to its 32px fallback
|
||||
// even without the WCO API; auto never does this.
|
||||
const update = await request.post(`${BASE_URL}/sidebar/api/settings.update`, {
|
||||
const update = await request.post(sidebarApi('settings.update'), {
|
||||
data: { patch: { titleBarScheme: 'preset', titleBarPresetId: 'dsh-desktop', titleBarCompat: true } },
|
||||
})
|
||||
expect(update.ok(), `settings.update: ${update.status()}`).toBe(true)
|
||||
try {
|
||||
await page.goto(`${BASE_URL}?dsh-desktop-mode=advanced&dsh-desktop-platform=win32`, { waitUntil: 'domcontentloaded' })
|
||||
await gotoPage(page, { 'dsh-desktop-mode': 'advanced', 'dsh-desktop-platform': 'win32' })
|
||||
await expect(page.locator('[data-dsh-better-sidebar]')).toBeAttached({ timeout: 90_000 })
|
||||
await expect(page.locator('body[data-dsh-title-bar-compat]')).toBeAttached({ timeout: 90_000 })
|
||||
await expect
|
||||
@@ -647,26 +624,26 @@ test('opt-in shell preset applies its strip when WCO is absent (data-driven, man
|
||||
await expect(page.locator('style[data-dsh-preset-css]')).toHaveCount(0)
|
||||
} finally {
|
||||
// Restore the shared server state for the lanes after this one.
|
||||
await request.post(`${BASE_URL}/sidebar/api/settings.update`, {
|
||||
await request.post(sidebarApi('settings.update'), {
|
||||
data: { patch: { titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false } },
|
||||
})
|
||||
}
|
||||
})
|
||||
|
||||
test('custom scheme injects the user stylesheet live', async ({ request, page }) => {
|
||||
const update = await request.post(`${BASE_URL}/sidebar/api/settings.update`, {
|
||||
const update = await request.post(sidebarApi('settings.update'), {
|
||||
data: { patch: { titleBarScheme: 'custom', customCss: 'html { --dsh-e2e-marker: 1; }', titleBarCompat: true } },
|
||||
})
|
||||
expect(update.ok(), `settings.update: ${update.status()}`).toBe(true)
|
||||
try {
|
||||
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' })
|
||||
await page.goto(PAGE_URL, { waitUntil: 'domcontentloaded' })
|
||||
await expect(page.locator('[data-dsh-better-sidebar]')).toBeAttached({ timeout: 90_000 })
|
||||
await expect(page.locator('style[data-dsh-custom-css="custom"]')).toBeAttached()
|
||||
// The injected CSS is live (a custom property the page can read back).
|
||||
const marker = await page.evaluate(() => getComputedStyle(document.documentElement).getPropertyValue('--dsh-e2e-marker').trim())
|
||||
expect(marker).toBe('1')
|
||||
} finally {
|
||||
await request.post(`${BASE_URL}/sidebar/api/settings.update`, {
|
||||
await request.post(sidebarApi('settings.update'), {
|
||||
data: { patch: { titleBarScheme: 'auto', titleBarPresetId: '', customCss: '', titleBarCompat: false } },
|
||||
})
|
||||
}
|
||||
|
||||
@@ -17,12 +17,8 @@
|
||||
import { mkdirSync, writeFileSync } from 'node:fs'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { test, expect, request, type APIRequestContext } from '@playwright/test'
|
||||
|
||||
const BASE_URL = process.env.DSH_E2E_URL
|
||||
if (!BASE_URL) {
|
||||
throw new Error('DSH_E2E_URL is not set — boot a DSH web instance with the plugin mounted and point this lane at it (see scripts/e2e-mount.sh)')
|
||||
}
|
||||
import { test, expect, type APIRequestContext } from '@playwright/test'
|
||||
import { PAGE_URL, createHostApi, hostRpc } from './host'
|
||||
|
||||
const WORKSPACE_PATH = process.env.DSH_E2E_TOGGLE_WORKSPACE ?? join(tmpdir(), 'dsh-e2e-toggle-workspace')
|
||||
|
||||
@@ -31,23 +27,12 @@ let api: APIRequestContext
|
||||
async function seedSession(): Promise<void> {
|
||||
mkdirSync(WORKSPACE_PATH, { recursive: true })
|
||||
writeFileSync(join(WORKSPACE_PATH, 'seed.txt'), 'toggle lane\n')
|
||||
const workspace = await api.post(`${BASE_URL}/api/workspace.create`, {
|
||||
data: { type: 'client-request', rpcId: 'e2e-toggle-workspace', method: 'workspace.create', payload: { path: WORKSPACE_PATH } },
|
||||
})
|
||||
expect(workspace.ok(), `workspace.create: ${workspace.status()} ${await workspace.text()}`).toBe(true)
|
||||
const workspaceBody = (await workspace.json()) as {
|
||||
result: { ok: true; value: { workspace: { workspaceId: string } } } | { ok: false; error: unknown }
|
||||
}
|
||||
expect(workspaceBody.result.ok).toBe(true)
|
||||
const workspaceId = (workspaceBody.result as { value: { workspace: { workspaceId: string } } }).value.workspace.workspaceId
|
||||
const session = await api.post(`${BASE_URL}/api/session.create`, {
|
||||
data: { type: 'client-request', rpcId: 'e2e-toggle-session', method: 'session.create', payload: { workspaceId } },
|
||||
})
|
||||
expect(session.ok(), `session.create: ${session.status()} ${await session.text()}`).toBe(true)
|
||||
const workspace = await hostRpc<{ workspace: { workspaceId: string } }>(api, 'workspace.create', { path: WORKSPACE_PATH })
|
||||
await hostRpc(api, 'session.create', { workspaceId: workspace.value.workspace.workspaceId })
|
||||
}
|
||||
|
||||
test.beforeAll(async () => {
|
||||
api = await request.newContext({ baseURL: BASE_URL })
|
||||
api = await createHostApi()
|
||||
await seedSession()
|
||||
})
|
||||
|
||||
@@ -64,7 +49,7 @@ interface FrameSample {
|
||||
}
|
||||
|
||||
test('bottom panel tracks the center column during the right-panel toggle transition (issue #315)', async ({ page }) => {
|
||||
await page.goto(BASE_URL, { waitUntil: 'domcontentloaded' })
|
||||
await page.goto(PAGE_URL, { waitUntil: 'domcontentloaded' })
|
||||
await expect(page.locator('#root > *')).not.toHaveCount(0, { timeout: 90_000 })
|
||||
const sidebar = page.locator('[data-dsh-better-sidebar]')
|
||||
await expect(sidebar).toBeAttached({ timeout: 90_000 })
|
||||
|
||||
Reference in New Issue
Block a user