mirror of
https://github.com/deepseek-ai/deepseek-harness
synced 2026-08-15 21:04:50 +00:00
Merge remote-tracking branch 'origin/master' into feat/loader-entry-disabled-interpolation
This commit is contained in:
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write apps/cli/README.md
|
||||
README.md: 4fae5338a89ce12c2620e123530acf883ae9efff
|
||||
README.zh.md: a2d086b8ff12fb07f2446fc4162de09739bcdeab
|
||||
README.md: 98a856261bc632c97f350db8fc7bb0b10c22235d
|
||||
README.zh.md: 283e54138e24202ed6b88d1d309538c58cd66b3e
|
||||
|
||||
@@ -31,8 +31,8 @@ dsh --help # the launcher's own help
|
||||
|
||||
A profile directory holds a `package.json` (out-of-tree plugin dependencies plus the profile manifest `dsh.profile` with its ordered `bundles` list) and a `cordis.patch.yml` (the user's own patch layer, hot-reloaded on long-lived surfaces). The tree composes over an empty root: each bundle's patch in `dsh.profile.bundles` order, then the profile's `cordis.patch.yml`, then the home-level `$DSH_HOME/cordis.patch.yml`, then `--patch` overlays. Bundles named in `dsh.profile.bundles` resolve from the dsh installation first (`@deepseek-ai/dsh-base`, `@deepseek-ai/dsh-web-app`, `@deepseek-ai/dsh-headless`), then from the profile's own `node_modules`, where pnpm installs out-of-tree plugins. Use `--dump-default-config` and `--dump-config` to inspect the composed tree without booting it.
|
||||
|
||||
The [CLI behavior reference](reference/README.md) owns exact layer precedence, flags, shutdown behavior, deployment defaults, and the source launcher.
|
||||
The [CLI behavior reference](reference/README.md) owns exact layer precedence, flags, shutdown behavior, deployment defaults, and source execution.
|
||||
|
||||
## Development
|
||||
|
||||
Production runs require built package and frontend artifacts. From a checkout, `pnpm run dsh` runs the TypeScript entry and forwards arguments; the [source-launcher reference](reference/README.md#source-launcher) describes the PATH symlink and module-resolution contract.
|
||||
Production runs require built package and frontend artifacts. From the repository root, `pnpm dsh <args...>` builds those artifacts, runs the TypeScript entry, and forwards every argument; the [source-execution reference](reference/README.md#source-execution) owns the module-resolution contract.
|
||||
|
||||
@@ -31,8 +31,8 @@ dsh --help # the launcher's own help
|
||||
|
||||
profile 目录包含一个 `package.json`(树外插件依赖,加上 profile manifest(元数据清单)`dsh.profile` 及其有序的 `bundles` 列表)和一个 `cordis.patch.yml`(用户自己的 patch 层,在长期运行的 surface 上热重载)。配置树在空根之上组合:先按 `dsh.profile.bundles` 顺序应用各组合包的 patch,然后是 profile 的 `cordis.patch.yml`,然后是 home 级的 `$DSH_HOME/cordis.patch.yml`,然后是 `--patch` overlay。`dsh.profile.bundles` 中列出的组合包先从 dsh 安装目录解析(`@deepseek-ai/dsh-base`、`@deepseek-ai/dsh-web-app`、`@deepseek-ai/dsh-headless`),再从 profile 自己的 `node_modules` 解析;pnpm 把树外插件安装在后者。使用 `--dump-default-config` 和 `--dump-config` 可在不启动的情况下检查组合后的配置树。
|
||||
|
||||
[CLI(命令行界面)行为参考](reference/README.md)负责确切的层优先级、flag、关闭行为、部署默认值和源码启动器。
|
||||
[CLI(命令行界面)行为参考](reference/README.md)负责确切的层优先级、flag、关闭行为、部署默认值和源码执行。
|
||||
|
||||
## 开发
|
||||
|
||||
生产运行需要已构建的包与前端产物。在 checkout 中,`pnpm run dsh` 会运行 TypeScript 入口并转发参数;[源码启动器参考](reference/README.md#source-launcher)说明 PATH 符号链接和模块解析契约。
|
||||
生产运行需要已构建的包与前端产物。从仓库根目录运行 `pnpm dsh <args...>` 会先构建这些产物,再运行 TypeScript 入口并转发所有参数;模块解析约定由[源码执行参考](reference/README.md#source-execution)负责。
|
||||
|
||||
@@ -2,5 +2,5 @@
|
||||
# side as of the last confirmed-consistent state. Both languages carry equal authority;
|
||||
# after editing either side, bring the other along and re-record with:
|
||||
# pnpm run verify-translation-pairing --write apps/cli/reference/README.md
|
||||
README.md: fd0647347312051a1814a5e3464b34032ae70dfc
|
||||
README.zh.md: afe4b9ba5651e962288ffebbe7c095ad20bcc617
|
||||
README.md: cef687dc392f97886ea18b162e8f668a44ce2284
|
||||
README.zh.md: f6721ec256d404e2b602fb2ed921b41c03633a82
|
||||
|
||||
@@ -79,12 +79,6 @@ Session events stream as OTLP/HTTP logs by default. `DSH_TELEMETRY_OTLP_URL` sel
|
||||
|
||||
Install external plugin bundles through `dsh plugin --profile <name> add <package-or-git-spec>`. The installed package owns its dependencies and contributes its declared `cordis.patch.yml` layer. The CLI also ships `@deepseek-ai/dsh-mcp-client` as a dependency for patch layers, but no MCP server is enabled by default because each server command is trusted executable code outside the agent sandbox.
|
||||
|
||||
## Source launcher
|
||||
## Source execution
|
||||
|
||||
Link the source-running launcher onto PATH:
|
||||
|
||||
```sh
|
||||
ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh
|
||||
```
|
||||
|
||||
It resolves the checkout through its real path and launches `apps/cli/src/bin.ts` with `node --import tsx/esm`. `TSX_TSCONFIG_PATH` is pinned to the checkout root, so workspace package resolution is independent of the invoking directory. `pnpm run dsh` uses the same entry and forwards arguments. The built form is `apps/cli/lib/bin.js` after `pnpm run build`.
|
||||
From the repository root, use `pnpm dsh <args...>`. The `package.json` script runs the complete repository build, launches `apps/cli/src/bin.ts` with `node --import tsx/esm`, and forwards every argument. Build output appears before CLI output. The process inherits the launch environment; set `NODE_USE_ENV_PROXY=1` when a supporting Node version must honor `HTTP_PROXY` and `HTTPS_PROXY`. The installed form launches the built `apps/cli/lib/bin.js` without rebuilding the repository.
|
||||
|
||||
@@ -79,12 +79,6 @@ dsh web --help
|
||||
|
||||
通过 `dsh plugin --profile <name> add <package-or-git-spec>` 安装外部插件组合包。安装的包拥有其依赖,并贡献其声明的 `cordis.patch.yml` 层。CLI 还随附 `@deepseek-ai/dsh-mcp-client` 作为供 patch 层使用的依赖,但默认不启用 MCP 服务器,因为每条服务器命令都是 agent(智能体)沙箱之外的受信任可执行代码。
|
||||
|
||||
## 源码启动器
|
||||
## 源码执行
|
||||
|
||||
把源码运行启动器链接到 PATH:
|
||||
|
||||
```sh
|
||||
ln -sf "$(pwd)/bin/dsh" ~/.local/bin/dsh
|
||||
```
|
||||
|
||||
它通过 real path 解析 checkout,并使用 `node --import tsx/esm` 启动 `apps/cli/src/bin.ts`。`TSX_TSCONFIG_PATH` 固定到 checkout 根目录,因此 workspace 包解析不依赖调用目录。`pnpm run dsh` 使用同一入口并转发参数。运行 `pnpm run build` 后,构建形式为 `apps/cli/lib/bin.js`。
|
||||
请从仓库根目录使用 `pnpm dsh <args...>`。`package.json` 中的脚本会完成整个仓库的构建,通过 `node --import tsx/esm` 启动 `apps/cli/src/bin.ts`,并转发所有参数。构建输出会显示在 CLI 输出之前。该进程会继承启动环境;当支持环境代理的 Node 版本必须遵循 `HTTP_PROXY` 和 `HTTPS_PROXY` 时,请设置 `NODE_USE_ENV_PROXY=1`。安装形式会直接启动构建后的 `apps/cli/lib/bin.js`,不会重新构建仓库。
|
||||
|
||||
@@ -85,14 +85,14 @@ describe('dsh badge assembled snapshot', () => {
|
||||
|
||||
- Local PNG: [\`dsh-badge.png\`](dsh-badge.png), 726×120 source image; render at 121×20
|
||||
- Shields.io image URL: \`https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white\`
|
||||
- Project URL: \`https://github.com/deepseek-ai/deepseek-harness-sdk\`
|
||||
- Project URL: \`https://github.com/deepseek-ai/deepseek-harness\`
|
||||
|
||||
## Markdown
|
||||
|
||||
Use this linked badge in Markdown:
|
||||
|
||||
\`\`\`markdown
|
||||
[](https://github.com/deepseek-ai/deepseek-harness-sdk)
|
||||
[](https://github.com/deepseek-ai/deepseek-harness)
|
||||
\`\`\`
|
||||
|
||||
If attribution should not be linked, use:
|
||||
@@ -124,14 +124,14 @@ describe('dsh badge assembled snapshot', () => {
|
||||
|
||||
- Local PNG: [\`dsh-badge.png\`](dsh-badge.png), 726×120 source image; render at 121×20
|
||||
- Shields.io image URL: \`https://img.shields.io/badge/powered_by-dsh-4D6BFE?style=flat-square&logo=deepseek&logoColor=white\`
|
||||
- Project URL: \`https://github.com/deepseek-ai/deepseek-harness-sdk\`
|
||||
- Project URL: \`https://github.com/deepseek-ai/deepseek-harness\`
|
||||
|
||||
## Markdown
|
||||
|
||||
Use this linked badge in Markdown:
|
||||
|
||||
\`\`\`markdown
|
||||
[](https://github.com/deepseek-ai/deepseek-harness-sdk)
|
||||
[](https://github.com/deepseek-ai/deepseek-harness)
|
||||
\`\`\`
|
||||
|
||||
If attribution should not be linked, use:
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
import { chmodSync, copyFileSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs'
|
||||
import { mkdtemp, rm } from 'node:fs/promises'
|
||||
import { tmpdir } from 'node:os'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { execa } from 'execa'
|
||||
import { afterEach, describe, expect, it } from 'vitest'
|
||||
|
||||
const installer = fileURLToPath(new URL('../../../scripts/install.sh', import.meta.url))
|
||||
const fixtures: string[] = []
|
||||
|
||||
const PTY_DRIVER = String.raw`
|
||||
import errno, json, os, pty, select, signal, sys, time
|
||||
script, cwd, env_json, actions_json = sys.argv[1:]
|
||||
env = os.environ.copy()
|
||||
env.update(json.loads(env_json))
|
||||
actions = json.loads(actions_json)
|
||||
pid, fd = pty.fork()
|
||||
if pid == 0:
|
||||
os.chdir(cwd)
|
||||
os.execvpe("sh", ["sh", script], env)
|
||||
|
||||
output = bytearray()
|
||||
action_index = 0
|
||||
deadline = time.monotonic() + 15
|
||||
status = None
|
||||
while time.monotonic() < deadline:
|
||||
ready, _, _ = select.select([fd], [], [], 0.05)
|
||||
if ready:
|
||||
try:
|
||||
chunk = os.read(fd, 65536)
|
||||
except OSError as error:
|
||||
if error.errno != errno.EIO:
|
||||
raise
|
||||
chunk = b""
|
||||
output.extend(chunk)
|
||||
while action_index < len(actions) and actions[action_index]["waitFor"].encode() in output:
|
||||
os.write(fd, actions[action_index]["send"].encode())
|
||||
action_index += 1
|
||||
waited, candidate = os.waitpid(pid, os.WNOHANG)
|
||||
if waited == pid:
|
||||
status = candidate
|
||||
break
|
||||
|
||||
if status is None:
|
||||
os.kill(pid, signal.SIGKILL)
|
||||
_, status = os.waitpid(pid, 0)
|
||||
sys.stdout.buffer.write(output)
|
||||
if action_index != len(actions):
|
||||
sys.stderr.write(f"completed {action_index}/{len(actions)} PTY actions\n")
|
||||
sys.exit(124)
|
||||
sys.exit(os.waitstatus_to_exitcode(status))
|
||||
`
|
||||
|
||||
interface Action {
|
||||
readonly waitFor: string
|
||||
readonly send: string
|
||||
}
|
||||
|
||||
interface Fixture {
|
||||
readonly binDirectory: string
|
||||
readonly launchLog: string
|
||||
readonly pnpmLog: string
|
||||
readonly root: string
|
||||
readonly script: string
|
||||
}
|
||||
|
||||
afterEach(async () => {
|
||||
await Promise.all(fixtures.splice(0).map(async (fixture) => { await rm(fixture, { force: true, recursive: true }) }))
|
||||
})
|
||||
|
||||
function executable(path: string, content: string): void {
|
||||
writeFileSync(path, content)
|
||||
chmodSync(path, 0o755)
|
||||
}
|
||||
|
||||
async function createFixture(): Promise<Fixture> {
|
||||
const root = await mkdtemp(join(tmpdir(), 'dsh-install-'))
|
||||
fixtures.push(root)
|
||||
const checkoutDirectory = join(root, 'checkout')
|
||||
const scriptsDirectory = join(checkoutDirectory, 'scripts')
|
||||
const sourceBinDirectory = join(checkoutDirectory, 'bin')
|
||||
const fakeBinDirectory = join(root, 'fake-bin')
|
||||
const binDirectory = join(root, 'path-bin')
|
||||
for (const directory of [scriptsDirectory, sourceBinDirectory, fakeBinDirectory, binDirectory, join(root, 'home/.dsh')]) {
|
||||
mkdirSync(directory, { recursive: true })
|
||||
}
|
||||
const script = join(scriptsDirectory, 'install.sh')
|
||||
copyFileSync(installer, script)
|
||||
const launchLog = join(root, 'launch.log')
|
||||
const pnpmLog = join(root, 'pnpm.log')
|
||||
executable(join(sourceBinDirectory, 'dsh'), '#!/bin/sh\nprintf \'%s\\n\' "$*" >"$DSH_TEST_LAUNCH_LOG"\n')
|
||||
executable(join(fakeBinDirectory, 'pnpm'), `#!/bin/sh
|
||||
if [ "\${1:-}" = --version ]; then printf '11.7.0\\n'; exit 0; fi
|
||||
printf '%s\\n' "$*" >>"$DSH_TEST_PNPM_LOG"
|
||||
`)
|
||||
await execa('git', ['init', '-q'], { cwd: checkoutDirectory })
|
||||
await execa('git', ['add', 'bin/dsh', 'scripts/install.sh'], { cwd: checkoutDirectory })
|
||||
await execa('git', [
|
||||
'-c', 'user.name=dsh-test',
|
||||
'-c', 'user.email=dsh-test@example.invalid',
|
||||
'commit', '-qm', 'fixture',
|
||||
], { cwd: checkoutDirectory })
|
||||
writeFileSync(join(root, 'home/.dsh/.env'), 'DEEPSEEK_API_KEY=test\n')
|
||||
return { binDirectory, launchLog, pnpmLog, root, script }
|
||||
}
|
||||
|
||||
async function runInstaller(fixture: Fixture, actions: readonly Action[]): Promise<string> {
|
||||
const result = await execa('python3', [
|
||||
'-c',
|
||||
PTY_DRIVER,
|
||||
fixture.script,
|
||||
fixture.root,
|
||||
JSON.stringify({
|
||||
DSH_BIN_DIR: fixture.binDirectory,
|
||||
DSH_HOME: join(fixture.root, 'home/.dsh'),
|
||||
DSH_TEST_LAUNCH_LOG: fixture.launchLog,
|
||||
DSH_TEST_PNPM_LOG: fixture.pnpmLog,
|
||||
HOME: join(fixture.root, 'home'),
|
||||
PATH: `${join(fixture.root, 'fake-bin')}:${fixture.binDirectory}:${process.env.PATH ?? ''}`,
|
||||
}),
|
||||
JSON.stringify(actions),
|
||||
], { reject: false, stripFinalNewline: false, timeout: 20_000 })
|
||||
expect(result.exitCode, result.stderr).toBe(0)
|
||||
return result.stdout
|
||||
}
|
||||
|
||||
describe.runIf(process.platform !== 'win32')('one-line installer launch', { timeout: 25_000 }, () => {
|
||||
it('builds and launches the Web UI', async () => {
|
||||
const fixture = await createFixture()
|
||||
|
||||
const output = await runInstaller(fixture, [
|
||||
{ waitFor: 'Replace it?', send: '\n' },
|
||||
])
|
||||
|
||||
expect(output).toContain('launching Web UI')
|
||||
expect(readFileSync(fixture.pnpmLog, 'utf8')).toBe('install\nrun build\n')
|
||||
expect(readFileSync(fixture.launchLog, 'utf8')).toBe('web\n')
|
||||
})
|
||||
})
|
||||
@@ -1,11 +1,12 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { execa } from 'execa'
|
||||
import { describe, expect, it } from 'vitest'
|
||||
|
||||
/**
|
||||
* Keyless smoke for the SOURCE `dsh` launcher: run `apps/cli/src/bin.ts`
|
||||
* with the exact production launch vector (`node --import tsx/esm`, the same
|
||||
* executable and arguments as `bin/dsh` and the root `dsh`/`demo:web` scripts) and assert the
|
||||
* Keyless smoke for SOURCE `dsh` execution: run `apps/cli/src/bin.ts`
|
||||
* with the exact production runtime vector (`node --import tsx/esm`, the
|
||||
* vector the root `dsh` script invokes after building) and assert the
|
||||
* required-config diagnostic. The Node compatibility matrix runs this
|
||||
* WHOLE file, so a Node release changing module hooks or TypeScript handling
|
||||
* breaks this gate instead of every developer's `pnpm dsh`; the built-bin
|
||||
@@ -16,6 +17,13 @@ const repoRoot = fileURLToPath(new URL('../../../', import.meta.url))
|
||||
const dshSourceBin = 'apps/cli/src/bin.ts'
|
||||
|
||||
describe('dsh SOURCE launcher (node --import tsx/esm)', () => {
|
||||
it('builds before launching the source CLI', async () => {
|
||||
const rootPackage = JSON.parse(await readFile(new URL('../../../package.json', import.meta.url), 'utf8')) as {
|
||||
readonly scripts?: Record<string, string>
|
||||
}
|
||||
expect(rootPackage.scripts?.dsh).toBe('pnpm run build && node --import tsx/esm apps/cli/src/bin.ts')
|
||||
})
|
||||
|
||||
it('boots the source entry and requires a profile', async () => {
|
||||
const result = await execa(process.execPath, ['--import', 'tsx/esm', dshSourceBin], {
|
||||
cwd: repoRoot,
|
||||
|
||||
@@ -159,7 +159,7 @@ describe('the shipped Web composition', () => {
|
||||
// depend on ripgrep being present on the machine.
|
||||
expect(toolNames(ctx, handle.agent).filter(name => name !== 'glob' && name !== 'grep')).toEqual([
|
||||
'ask_user_question', 'bash', 'create_goal', 'edit', 'exit_plan_mode',
|
||||
'get_goal', 'interrupt_agent', 'list_agents', 'ralph', 'read', 'send_message', 'skill',
|
||||
'get_goal', 'interrupt_agent', 'list_agents', 'ralph', 'read', 'read_image', 'send_message', 'skill',
|
||||
'subagent', 'subagent_fork', 'task_kill',
|
||||
'task_list', 'task_output', 'todo_write', 'update_goal', 'web_search',
|
||||
'workflow', 'write',
|
||||
|
||||
@@ -176,8 +176,10 @@ describe('web e2e: agent-preset authoring is a host-side copy', () => {
|
||||
|
||||
await expect.poll(async () => dialog.getByText('我的模式').count(), { timeout: 10_000 }).toBe(0)
|
||||
expect(existsSync(join(userRoot, 'my-agent'))).toBe(false)
|
||||
// Custom group gone with its only member; the shipped set stands.
|
||||
expect(await dialog.getByRole('heading', { name: '自定义' }).count()).toBe(0)
|
||||
// The custom group outlives its only member: the heading stays with the
|
||||
// creator entry so the place to author a preset never disappears.
|
||||
expect(await dialog.getByRole('heading', { name: '自定义' }).count()).toBe(1)
|
||||
expect(await dialog.getByRole('button', { name: '用「创造模式」创作自定义预设' }).count()).toBe(1)
|
||||
expect(await dialog.getByText('标准模式').count()).toBeGreaterThan(0)
|
||||
}, 60_000)
|
||||
|
||||
|
||||
133
apps/web/tests/background-task-list.e2e.ts
Normal file
133
apps/web/tests/background-task-list.e2e.ts
Normal file
@@ -0,0 +1,133 @@
|
||||
// Web e2e scenario: the session-header background-task list over the real
|
||||
// host. No model call is involved — a genuine `run_in_background` bash call
|
||||
// registers with `ctx.tasks`, and the assertion chain is the whole delivery
|
||||
// path: registry change feed → api-proxy `session/tasks` frame → the client's
|
||||
// `tasksBySession` mirror → the header action.
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import type { Agent } from '@deepseek-ai/dsh-agent'
|
||||
import { CallId } from '@deepseek-ai/dsh-llm'
|
||||
import { SessionId } from '@deepseek-ai/dsh-session'
|
||||
import { TaskId } from '@deepseek-ai/dsh-tasks'
|
||||
import {
|
||||
assertFixtureInventory, captureStableAria, compareOrRefreshGolden,
|
||||
launchWebScaffold, seedSession, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const FIXTURE = fileURLToPath(new URL('./snapshots/fresh-round-trip/session.jsonl', import.meta.url))
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/background-task-list', import.meta.url))
|
||||
const RUNNING_EXPECTED = join(SNAPSHOT_DIR, 'running.expected.md')
|
||||
const SETTLED_EXPECTED = join(SNAPSHOT_DIR, 'settled.expected.md')
|
||||
const MODE = webSnapshotMode()
|
||||
const SEED_ID = 'background-task-list-web-e2e'
|
||||
// Long enough that the running assertions never race the process exiting on
|
||||
// their own; the test kills it explicitly to reach the settled state.
|
||||
const COMMAND = 'sleep 45'
|
||||
|
||||
/**
|
||||
* Wait for the Host to publish the live Agent that opening a session resumes.
|
||||
* @param scaffold - the booted web scaffold.
|
||||
* @param sessionId - the opened session's identity.
|
||||
* @returns the registered Agent instance.
|
||||
*/
|
||||
async function liveAgent(scaffold: WebScaffold, sessionId: SessionId): Promise<Agent> {
|
||||
const deadline = Date.now() + 30_000
|
||||
for (;;) {
|
||||
const found = scaffold.ctx.agents.get(sessionId)
|
||||
if (found !== undefined) return found
|
||||
if (Date.now() > deadline) throw new Error(`opening session "${sessionId}" published no live Agent`)
|
||||
await new Promise(resolve => setTimeout(resolve, 100))
|
||||
}
|
||||
}
|
||||
|
||||
describe.skipIf(MODE === 'record')('web e2e: background task list', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
let agent: Agent
|
||||
let taskId: TaskId
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({})
|
||||
await seedSession(scaffold, await readFile(FIXTURE, 'utf8'), SEED_ID)
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
|
||||
const groupRow = page.locator('[role="treeitem"]').first()
|
||||
await groupRow.waitFor({ timeout: 15_000 })
|
||||
await groupRow.click()
|
||||
const sessionRow = page.locator('[role="treeitem"]').nth(1)
|
||||
await sessionRow.waitFor({ timeout: 10_000 })
|
||||
await sessionRow.click()
|
||||
|
||||
// Opening the session drives the Host's ordinary Agent resolution; the
|
||||
// task owner must be that exact live instance, never a second one.
|
||||
// `expect.poll` is test-scoped, so this hook polls by hand.
|
||||
agent = await liveAgent(scaffold, SessionId(SEED_ID))
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
})
|
||||
|
||||
it('shows a running background task in the session header without a refresh', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-background-task-running'))
|
||||
// Point assertion, not a poll: `expect.poll` retries until a predicate
|
||||
// holds, so polling for zero passes at t=0 and proves nothing. The
|
||||
// "renders nothing without a task" branch is owned by the component suite.
|
||||
const trigger = page.getByRole('button', { name: '1 background task running' })
|
||||
expect(await trigger.count()).toBe(0)
|
||||
|
||||
const started = await scaffold.ctx.tools.execute({
|
||||
signal: new AbortController().signal,
|
||||
callId: CallId('background-task-list-e2e'),
|
||||
name: 'bash',
|
||||
arguments: { command: COMMAND, description: 'Hold a background slot open', run_in_background: true },
|
||||
agent,
|
||||
})
|
||||
const reported = started.content.map(block => block.type === 'text' ? block.text : '').join('')
|
||||
const matched = /\bbash-\d+\b/.exec(reported)
|
||||
if (matched === null) throw new Error(`background bash reported no task id: ${reported}`)
|
||||
taskId = TaskId(matched[0])
|
||||
|
||||
await trigger.waitFor({ timeout: 15_000 })
|
||||
await trigger.click()
|
||||
const row = page.getByRole('list', { name: 'Background tasks' }).getByRole('listitem').first()
|
||||
await row.waitFor({ timeout: 10_000 })
|
||||
await expect.poll(() => row.textContent()).toContain(COMMAND)
|
||||
|
||||
const snapshot = await captureStableAria(page, '[class*="menu"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(RUNNING_EXPECTED, snapshot, MODE)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('flips the open list to the cancelled outcome when the registry settles it', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-background-task-settled'))
|
||||
expect(scaffold.ctx.tasks.kill(taskId, agent, 'web e2e cancellation')).toBe('requested')
|
||||
|
||||
// The trigger drops its live count once the task leaves running/stopping,
|
||||
// which is also the proof that settlement reached the browser unprompted.
|
||||
const idle = page.getByRole('button', { name: '1 background task' })
|
||||
await idle.waitFor({ timeout: 20_000 })
|
||||
|
||||
const snapshot = await captureStableAria(page, '[class*="menu"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(SETTLED_EXPECTED, snapshot, MODE)
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it('keeps its snapshot inventory closed', async () => {
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['running.expected.md', 'settled.expected.md'])
|
||||
})
|
||||
})
|
||||
101
apps/web/tests/feedback-command.e2e.ts
Normal file
101
apps/web/tests/feedback-command.e2e.ts
Normal file
@@ -0,0 +1,101 @@
|
||||
// Keyless assembled-browser coverage for the /feedback command over the
|
||||
// shipped Web bundles and the real host wire. The command plane settles
|
||||
// without a model turn: the host appends the log-only command/run +
|
||||
// feedback/record + command/done lifecycle, and the transcript renders the
|
||||
// acknowledgement — the recorded session id plus the session-sharing
|
||||
// disclosure — as a persistent command row. The scaffold mounts the shipped
|
||||
// telemetry row in FULL mode against a local dead endpoint (no record leaves
|
||||
// the process), so the golden pins the shipped default sentence
|
||||
// `Session sharing is enabled.`; the per-status sentences are pinned by the
|
||||
// package and OTel unit tests.
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { join } from 'node:path'
|
||||
import type { Browser, Page } from 'playwright'
|
||||
import { chromium } from 'playwright'
|
||||
import { afterAll, beforeAll, describe, expect, it, onTestFailed } from 'vitest'
|
||||
import {
|
||||
assertFixtureInventory, captureStableAria, compareOrRefreshGolden, fixtureUserPrompts,
|
||||
launchWebScaffold, recordFixture, watchConsole, webSnapshotMode, type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
import { connectFreshWorkspace, newEnglishPage, saveFailureShot } from './support.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/feedback-command', import.meta.url))
|
||||
const FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
|
||||
const ACK_EXPECTED = join(SNAPSHOT_DIR, 'ack.expected.md')
|
||||
const MODE = webSnapshotMode()
|
||||
// Discard port: loopback listener never binds, so FULL telemetry discloses
|
||||
// the shipped default policy without any record reaching a collector.
|
||||
const TELEMETRY_URL = 'http://127.0.0.1:9/v1/logs'
|
||||
|
||||
const PROMPT = 'Reply with the single word LIGHTHOUSE and stop.'
|
||||
|
||||
describe('web e2e: /feedback command acknowledgement', () => {
|
||||
let scaffold: WebScaffold
|
||||
let browser: Browser
|
||||
let page: Page
|
||||
let tripwire: ReturnType<typeof watchConsole>
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({
|
||||
telemetryUrl: TELEMETRY_URL,
|
||||
...(MODE === 'record' ? {} : { replayFixture: FIXTURE }),
|
||||
})
|
||||
browser = await chromium.launch()
|
||||
page = await newEnglishPage(browser)
|
||||
tripwire = watchConsole(page)
|
||||
await page.goto(scaffold.baseUrl, { waitUntil: 'load' })
|
||||
await page.waitForSelector('[class*="frame"]', { timeout: 30_000 })
|
||||
// Fresh world: connecting a workspace births the blank session whose
|
||||
// live composer accepts the slash line.
|
||||
await connectFreshWorkspace(page, scaffold.workspaceCwd)
|
||||
}, 120_000)
|
||||
|
||||
afterAll(async () => {
|
||||
await browser?.close()
|
||||
await scaffold?.close()
|
||||
})
|
||||
|
||||
it('drives the recorded prompt to a settled turn (all modes)', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-feedback-drive'))
|
||||
if (MODE !== 'record') {
|
||||
// Drift guard: the committed fixture must carry exactly the drive prompt.
|
||||
expect(fixtureUserPrompts(await readFile(FIXTURE, 'utf8'))).toEqual([PROMPT])
|
||||
}
|
||||
const input = page.locator('textarea').first()
|
||||
await input.waitFor({ timeout: 10_000 })
|
||||
// Arm the turn-boundary waiter BEFORE sending, so a burst replay cannot
|
||||
// miss the turn/end that settles the recorded turn.
|
||||
const settled = scaffold.whenTurnSettled()
|
||||
await input.fill(PROMPT)
|
||||
await input.press('Enter')
|
||||
const sessionId = await settled
|
||||
if (MODE === 'record') {
|
||||
await recordFixture(scaffold, sessionId, FIXTURE)
|
||||
}
|
||||
}, 60_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('records feedback and renders the acknowledgement with session id and sharing status', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-feedback-command'))
|
||||
// The drive test settled the recorded turn: the transcript is active (a
|
||||
// command row does not render while a fresh session is still blank) and
|
||||
// the replayed reply is on screen.
|
||||
await page.getByText('LIGHTHOUSE', { exact: true }).waitFor({ timeout: 15_000 })
|
||||
const input = page.locator('textarea').first()
|
||||
await input.fill('/feedback the diff view is unreadable')
|
||||
await input.press('Enter')
|
||||
// The command plane settles without a model turn: the ack row names the
|
||||
// recorded session and the mounted FULL backend's disclosure.
|
||||
await page.getByText(/Feedback recorded for session/).waitFor({ timeout: 10_000 })
|
||||
expect(await page.getByText(/Session sharing is enabled/).count()).toBe(1)
|
||||
const snapshot = await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd)
|
||||
await compareOrRefreshGolden(ACK_EXPECTED, snapshot, MODE)
|
||||
|
||||
expect(tripwire.pageErrors).toEqual([])
|
||||
expect(tripwire.warnings).toEqual([])
|
||||
}, 60_000)
|
||||
|
||||
it.skipIf(MODE === 'record')('keeps the fixture inventory closed', async () => {
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['session.jsonl', 'ack.expected.md'])
|
||||
})
|
||||
})
|
||||
@@ -119,6 +119,10 @@ describe('web e2e: settled Markdown math rendering', () => {
|
||||
await expect.poll(() => page.locator('.katex').count(), { timeout: 10_000 }).toBe(6)
|
||||
await expect.poll(() => page.locator('.katex-display').count(), { timeout: 10_000 }).toBe(2)
|
||||
expect(await page.locator('.katex-error').count()).toBe(0)
|
||||
await expect.poll(
|
||||
() => page.getByText('Input 0 tok · Output 0 tok', { exact: false }).count(),
|
||||
{ timeout: 10_000 },
|
||||
).toBe(1)
|
||||
|
||||
const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
|
||||
.split(SEED_ID).join('{{seededId}}')
|
||||
|
||||
@@ -128,6 +128,7 @@ describe('web e2e: message IconActions and clocks on settled history', () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-message-actions-aria'))
|
||||
await page.getByRole('button', { name: /^Select model, current/ })
|
||||
.waitFor({ timeout: 10_000 })
|
||||
await page.getByText(/Cache hit \d+%/u).first().waitFor({ timeout: 10_000 })
|
||||
// Keep a footer focused so opacity-hidden actions stay in the a11y tree
|
||||
// as an active/focused control during the capture.
|
||||
await page.getByRole('button', { name: 'Copy' }).first().focus()
|
||||
|
||||
115
apps/web/tests/message-feedback-protocol.snapshot.ts
Normal file
115
apps/web/tests/message-feedback-protocol.snapshot.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import { readFile } from 'node:fs/promises'
|
||||
import { join } from 'node:path'
|
||||
import { fileURLToPath } from 'node:url'
|
||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest'
|
||||
import {
|
||||
assertFixtureInventory,
|
||||
compareOrRefreshGolden,
|
||||
launchWebScaffold,
|
||||
seedSession,
|
||||
type WebScaffold,
|
||||
} from './scaffold.ts'
|
||||
|
||||
const SNAPSHOT_DIR = fileURLToPath(new URL('./snapshots/message-feedback-protocol', import.meta.url))
|
||||
const SESSION_FIXTURE = join(SNAPSHOT_DIR, 'session.jsonl')
|
||||
const PROTOCOL_EXPECTED = join(SNAPSHOT_DIR, 'protocol.expected.json')
|
||||
const SESSION_ID = 'message-feedback-protocol'
|
||||
const MESSAGE_ID = '11111111-1111-4111-8111-111111111111'
|
||||
|
||||
interface ProtocolExchange {
|
||||
readonly endpoint: string
|
||||
readonly request: unknown
|
||||
readonly status: number
|
||||
readonly response: unknown
|
||||
}
|
||||
|
||||
function isRecord(value: unknown): value is Record<string, unknown> {
|
||||
return typeof value === 'object' && value !== null
|
||||
}
|
||||
|
||||
/** Extract the opaque item version while keeping every surrounding wire field snapshot-owned. */
|
||||
function createdVersion(response: unknown): string {
|
||||
if (!isRecord(response) || !isRecord(response.result) || response.result.ok !== true
|
||||
|| !isRecord(response.result.value) || response.result.value.ok !== true
|
||||
|| !isRecord(response.result.value.value)
|
||||
|| typeof response.result.value.value.version !== 'string') {
|
||||
throw new Error('messageFeedback.put did not return a successful versioned item')
|
||||
}
|
||||
return response.result.value.value.version
|
||||
}
|
||||
|
||||
/** Replace only run-owned UUID/time values; all protocol names and business fields stay exact. */
|
||||
function normalizeProtocol(exchanges: readonly ProtocolExchange[], version: string): string {
|
||||
return JSON.stringify(exchanges, (key, value: unknown) => {
|
||||
if ((key === 'version' || key === 'ifVersion') && value === version) return '{{version}}'
|
||||
if ((key === 'createdAt' || key === 'updatedAt') && typeof value === 'number') return '{{timestamp}}'
|
||||
return value
|
||||
}, 2)
|
||||
}
|
||||
|
||||
describe('message feedback Host Remote protocol', () => {
|
||||
let scaffold: WebScaffold
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold()
|
||||
await seedSession(scaffold, await readFile(SESSION_FIXTURE, 'utf8'), SESSION_ID)
|
||||
})
|
||||
|
||||
afterAll(async () => {
|
||||
await scaffold?.close()
|
||||
})
|
||||
|
||||
it('snapshots strict list, put, conflict, and delete calls through the shipped Web Host', async () => {
|
||||
const exchanges: ProtocolExchange[] = []
|
||||
const invoke = async (rpcId: string, endpoint: string, request: unknown): Promise<unknown> => {
|
||||
const payload = { args: { request } }
|
||||
const response = await fetch(`${scaffold.baseUrl}/api/${endpoint}`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
type: 'client-request',
|
||||
rpcId,
|
||||
method: endpoint,
|
||||
payload,
|
||||
}),
|
||||
})
|
||||
const body: unknown = await response.json()
|
||||
exchanges.push({ endpoint: `/api/${endpoint}`, request: payload, status: response.status, response: body })
|
||||
return body
|
||||
}
|
||||
|
||||
await invoke('feedback-invalid', 'messageFeedback/put', {
|
||||
sessionId: SESSION_ID,
|
||||
messageId: MESSAGE_ID,
|
||||
rating: 'invalid-rating',
|
||||
ifVersion: null,
|
||||
})
|
||||
await invoke('feedback-list-empty', 'messageFeedback/list', { sessionId: SESSION_ID })
|
||||
const created = await invoke('feedback-put', 'messageFeedback/put', {
|
||||
sessionId: SESSION_ID,
|
||||
messageId: MESSAGE_ID,
|
||||
rating: 'positive',
|
||||
note: 'Useful answer',
|
||||
ifVersion: null,
|
||||
})
|
||||
const version = createdVersion(created)
|
||||
expect(version).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/)
|
||||
await invoke('feedback-list-created', 'messageFeedback/list', { sessionId: SESSION_ID })
|
||||
await invoke('feedback-conflict', 'messageFeedback/put', {
|
||||
sessionId: SESSION_ID,
|
||||
messageId: MESSAGE_ID,
|
||||
rating: 'negative',
|
||||
ifVersion: null,
|
||||
})
|
||||
await invoke('feedback-delete', 'messageFeedback/delete', {
|
||||
sessionId: SESSION_ID,
|
||||
messageId: MESSAGE_ID,
|
||||
ifVersion: version,
|
||||
})
|
||||
await invoke('feedback-list-deleted', 'messageFeedback/list', { sessionId: SESSION_ID })
|
||||
|
||||
expect(exchanges.every(exchange => exchange.status === 200)).toBe(true)
|
||||
await compareOrRefreshGolden(PROTOCOL_EXPECTED, normalizeProtocol(exchanges, version), scaffold.mode)
|
||||
await assertFixtureInventory(SNAPSHOT_DIR, ['protocol.expected.json', 'session.jsonl'])
|
||||
})
|
||||
})
|
||||
@@ -245,6 +245,13 @@ export interface LaunchOptions {
|
||||
}
|
||||
/** Leave the current welcome notice unacknowledged; ordinary scenarios publish it as complete before browser boot. */
|
||||
welcomeNoticePending?: boolean
|
||||
/**
|
||||
* Mount the shipped telemetry row in FULL mode against this exporter URL
|
||||
* instead of disabling it. Used to pin a real backend disclosure in
|
||||
* assembled coverage; point the URL at a local dead endpoint so no record
|
||||
* leaves the process.
|
||||
*/
|
||||
telemetryUrl?: string
|
||||
/**
|
||||
* Browse through a trusted non-loopback hostname that the browser resolves
|
||||
* to loopback (for example `*.localhost`). The test server stays bound to
|
||||
@@ -334,6 +341,7 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
} catch (error) {
|
||||
const failures: unknown[] = [error]
|
||||
await rm(workspaceCwd, { recursive: true, force: true }).catch((cleanupError: unknown) => failures.push(cleanupError))
|
||||
restoreSkillRootEnvironment()
|
||||
if (failures.length > 1) throw new AggregateError(failures, 'web scaffold temp-root setup failed')
|
||||
throw error
|
||||
}
|
||||
@@ -395,8 +403,11 @@ export async function launchWebScaffold(options: LaunchOptions = {}): Promise<We
|
||||
{ id: 'session-title-llm', disabled: true },
|
||||
// Fixture sessions must never leave the process: the shipped row defaults
|
||||
// to the production OTLP endpoint (or whatever DSH_TELEMETRY_OTLP_URL
|
||||
// names in the ambient environment).
|
||||
{ id: 'telemetry-otel', disabled: true },
|
||||
// names in the ambient environment). A scenario that pins a real backend
|
||||
// disclosure passes a local dead endpoint instead of disabling the row.
|
||||
options.telemetryUrl === undefined
|
||||
? { id: 'telemetry-otel', disabled: true }
|
||||
: { id: 'telemetry-otel', config: { exporter: { url: options.telemetryUrl }, shutdownTimeoutMillis: 1_000 } },
|
||||
{
|
||||
id: 'webserver',
|
||||
config: { host: '127.0.0.1', port: 0 },
|
||||
|
||||
@@ -468,9 +468,9 @@ describe('web e2e: seeded history renders through cold resume', () => {
|
||||
if (done?.type !== 'command/done') throw new Error('feedback command did not settle')
|
||||
const [sessionLine, userLine, extraLine] = done.data.text?.split('\n') ?? []
|
||||
expect(sessionLine).toBe(`Feedback recorded for session ${SEED_ID}`)
|
||||
expect(userLine).toMatch(/^User: [0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i)
|
||||
expect(userLine).toMatch(/^User: [0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}\./i)
|
||||
expect(extraLine).toBeUndefined()
|
||||
const userId = userLine?.slice('User: '.length)
|
||||
const userId = userLine?.match(/^User: ([0-9a-f-]+)/i)?.[1]
|
||||
if (userId === undefined) throw new Error('feedback command omitted the user id')
|
||||
|
||||
const snapshot = (await captureStableAria(page, '[class*="centerCol"]', scaffold.workspaceCwd))
|
||||
|
||||
@@ -36,6 +36,7 @@ const EXPECTED_TOOLS = [
|
||||
'list_agents',
|
||||
'ralph',
|
||||
'read',
|
||||
'read_image',
|
||||
'send_message',
|
||||
'skill',
|
||||
'subagent',
|
||||
|
||||
@@ -478,7 +478,7 @@ describe.skipIf(!process.env.DEEPSEEK_API_KEY || notReady.length > 0)('web smoke
|
||||
requireDist()
|
||||
sessionsDir = mkdtempSync(join(tmpdir(), 'dsh-web-w5-'))
|
||||
const port = await probeFreePort()
|
||||
// tsx boot mirrors demo:web — lib/ may be unbuilt in this worktree. Isolate
|
||||
// tsx boot mirrors the runtime half of the root dsh script. Isolate
|
||||
// the host-level Harness and shared-agent homes inside the temp world; tsx
|
||||
// also needs the repo's loader and tsconfig paths pointed at explicitly.
|
||||
const tsxLoader = pathToFileURL(createRequire(join(REPO_ROOT, 'package.json')).resolve('tsx')).href
|
||||
|
||||
@@ -58,6 +58,7 @@
|
||||
- 'button "复制: 创造模式"':
|
||||
- img
|
||||
- text: 复制
|
||||
- heading "自定义" [level=3]
|
||||
- button "用「创造模式」创作自定义预设":
|
||||
- img
|
||||
- text: 用「创造模式」创作自定义预设
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
- list "Background tasks":
|
||||
- listitem: bash sleep 45 running {{duration}}
|
||||
@@ -0,0 +1,2 @@
|
||||
- list "Background tasks":
|
||||
- listitem: "bash sleep 45 signal: SIGTERM {{duration}}"
|
||||
39
apps/web/tests/snapshots/feedback-command/ack.expected.md
Normal file
39
apps/web/tests/snapshots/feedback-command/ack.expected.md
Normal file
@@ -0,0 +1,39 @@
|
||||
- banner:
|
||||
- navigation "Session hierarchy":
|
||||
- button "Reply with the single word" [disabled]
|
||||
- img
|
||||
- text: Standard mode
|
||||
- tablist:
|
||||
- tab "Chat" [selected]
|
||||
- tab "Trajectory"
|
||||
- text: Reply with the single word LIGHTHOUSE and stop. {{clock}}
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Context injection @deepseek-ai/dsh-system-prompt":
|
||||
- img
|
||||
- img
|
||||
- text: Context injection @deepseek-ai/dsh-system-prompt
|
||||
- button "Think The user wants me to reply with a single word. Let me comply.":
|
||||
- img
|
||||
- img
|
||||
- text: Think The user wants me to reply with a single word. Let me comply.
|
||||
- paragraph: LIGHTHOUSE
|
||||
- button "Copy":
|
||||
- img
|
||||
- button "Branch into a new conversation":
|
||||
- img
|
||||
- text: {{clock}} Ran for {{duration}} TTFT {{duration}} {{throughput}} tok/s
|
||||
- 'button "feedback Feedback recorded for session session-{{uuid}} User: {{uuid}}. Session sharing is enabled."':
|
||||
- img
|
||||
- img
|
||||
- text: "feedback Feedback recorded for session session-{{uuid}} User: {{uuid}}. Session sharing is enabled."
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
- 'button "Access mode, current: Workspace Write"': Workspace Write
|
||||
- button "Select model, current DeepSeek-V4-Flash":
|
||||
- text: DeepSeek-V4-Flash
|
||||
- img
|
||||
- button "6% of context used"
|
||||
- button "Send message" [disabled]
|
||||
- text: 1 turns · 1 steps LLM {{duration}} TTFT avg {{duration}} · {{throughput}} tok/s Cache hit 99% Input 7.8K tok · Output 21 tok
|
||||
17
apps/web/tests/snapshots/feedback-command/session.jsonl
Normal file
17
apps/web/tests/snapshots/feedback-command/session.jsonl
Normal file
@@ -0,0 +1,17 @@
|
||||
{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1785015039278,"cwd":"{{cwd}}/workspace"}
|
||||
{"type":"turn/start","seq":0,"time":1785015039291,"data":{"turn":1,"trigger":{"kind":"message","source":{"kind":"user","rpcId":"{{rpcId}}"}}}}
|
||||
{"type":"user/message","seq":1,"time":1785015039292,"data":{"content":[{"type":"text","text":"Reply with the single word LIGHTHOUSE and stop."}],"source":{"kind":"user","rpcId":"{{rpcId}}"}},"surfaceOp":"append"}
|
||||
{"type":"session/title","seq":2,"time":1785015039294,"data":{"title":"Reply with the single word","messageSeqs":[1],"source":{"kind":"fallback"}}}
|
||||
{"type":"step/start","seq":3,"time":1785015039362,"data":{"turn":1,"step":1}}
|
||||
{"type":"request/header","seq":4,"time":1785015039363,"data":{"header":{"config":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"system":"{{system}}","tools":"{{tools}}","messagePrefix":["{{messagePrefix}}"]},"reason":"initial"}}
|
||||
{"type":"assistant/chunk","seq":5,"time":1785015039930,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":0,"blockType":"reasoning"}}}
|
||||
{"type":"reasoning-chunks","seq0":6,"time0":1785015039930,"data":{"turn":1,"step":1,"index":0,"dt":[162,28,1,0,0,46,1,0,0,0,11,0,0,30],"texts":["The"," user"," wants"," me"," to"," reply"," with"," a"," single"," word","."," Let"," me"," comply","."]}}
|
||||
{"type":"assistant/chunk","seq":21,"time":1785015040209,"data":{"turn":1,"step":1,"chunk":{"type":"block-start","index":1,"blockType":"text"}}}
|
||||
{"type":"text-chunks","seq0":22,"time0":1785015040209,"data":{"turn":1,"step":1,"index":1,"dt":[1,0,30,1],"texts":["L","IGH","TH","O","USE"]}}
|
||||
{"type":"assistant/chunk","seq":27,"time":1785015040241,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":0,"block":{"type":"reasoning","text":"The user wants me to reply with a single word. Let me comply."}}}}
|
||||
{"type":"assistant/chunk","seq":28,"time":1785015040242,"data":{"turn":1,"step":1,"chunk":{"type":"block-end","index":1,"block":{"type":"text","text":"LIGHTHOUSE"}}}}
|
||||
{"type":"assistant/chunk","seq":29,"time":1785015040242,"data":{"turn":1,"step":1,"chunk":{"type":"usage","usage":{"inputTokens":109,"outputTokens":21,"cacheReadTokens":7680,"reasoningTokens":15}}}}
|
||||
{"type":"assistant/chunk","seq":30,"time":1785015040242,"data":{"turn":1,"step":1,"chunk":{"type":"finish","reason":{"kind":"stop"}}}}
|
||||
{"type":"assistant/message","seq":31,"time":1785015040244,"data":{"turn":1,"step":1,"content":[{"type":"reasoning","text":"The user wants me to reply with a single word. Let me comply."},{"type":"text","text":"LIGHTHOUSE"}],"provenance":{"provider":"deepseek-official","model":"deepseek-v4-flash"},"usage":{"inputTokens":109,"outputTokens":21,"cacheReadTokens":7680,"reasoningTokens":15}},"sourceEventSeqs":[5,6,7,8,9,10,11,12,13,14,15,16,17,18,19,20,21,22,23,24,25,26,27,28,29,30],"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":32,"time":1785015040246,"data":{"turn":1,"step":1}}
|
||||
{"type":"turn/end","seq":33,"time":1785015040247,"data":{"turn":1,"reason":{"kind":"completed"}}}
|
||||
@@ -0,0 +1,203 @@
|
||||
[
|
||||
{
|
||||
"endpoint": "/api/messageFeedback/put",
|
||||
"request": {
|
||||
"args": {
|
||||
"request": {
|
||||
"sessionId": "message-feedback-protocol",
|
||||
"messageId": "11111111-1111-4111-8111-111111111111",
|
||||
"rating": "invalid-rating",
|
||||
"ifVersion": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"status": 200,
|
||||
"response": {
|
||||
"type": "server-response",
|
||||
"rpcId": "feedback-invalid",
|
||||
"result": {
|
||||
"ok": false,
|
||||
"error": {
|
||||
"code": "internal",
|
||||
"message": "typert gateway: messageFeedback/put: wire field \"request\" failed boundary validation",
|
||||
"details": {}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"endpoint": "/api/messageFeedback/list",
|
||||
"request": {
|
||||
"args": {
|
||||
"request": {
|
||||
"sessionId": "message-feedback-protocol"
|
||||
}
|
||||
}
|
||||
},
|
||||
"status": 200,
|
||||
"response": {
|
||||
"type": "server-response",
|
||||
"rpcId": "feedback-list-empty",
|
||||
"result": {
|
||||
"ok": true,
|
||||
"value": {
|
||||
"ok": true,
|
||||
"value": {
|
||||
"items": []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"endpoint": "/api/messageFeedback/put",
|
||||
"request": {
|
||||
"args": {
|
||||
"request": {
|
||||
"sessionId": "message-feedback-protocol",
|
||||
"messageId": "11111111-1111-4111-8111-111111111111",
|
||||
"rating": "positive",
|
||||
"note": "Useful answer",
|
||||
"ifVersion": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"status": 200,
|
||||
"response": {
|
||||
"type": "server-response",
|
||||
"rpcId": "feedback-put",
|
||||
"result": {
|
||||
"ok": true,
|
||||
"value": {
|
||||
"ok": true,
|
||||
"value": {
|
||||
"messageId": "11111111-1111-4111-8111-111111111111",
|
||||
"rating": "positive",
|
||||
"note": "Useful answer",
|
||||
"version": "{{version}}",
|
||||
"createdAt": "{{timestamp}}",
|
||||
"updatedAt": "{{timestamp}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"endpoint": "/api/messageFeedback/list",
|
||||
"request": {
|
||||
"args": {
|
||||
"request": {
|
||||
"sessionId": "message-feedback-protocol"
|
||||
}
|
||||
}
|
||||
},
|
||||
"status": 200,
|
||||
"response": {
|
||||
"type": "server-response",
|
||||
"rpcId": "feedback-list-created",
|
||||
"result": {
|
||||
"ok": true,
|
||||
"value": {
|
||||
"ok": true,
|
||||
"value": {
|
||||
"items": [
|
||||
{
|
||||
"messageId": "11111111-1111-4111-8111-111111111111",
|
||||
"rating": "positive",
|
||||
"note": "Useful answer",
|
||||
"version": "{{version}}",
|
||||
"createdAt": "{{timestamp}}",
|
||||
"updatedAt": "{{timestamp}}"
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"endpoint": "/api/messageFeedback/put",
|
||||
"request": {
|
||||
"args": {
|
||||
"request": {
|
||||
"sessionId": "message-feedback-protocol",
|
||||
"messageId": "11111111-1111-4111-8111-111111111111",
|
||||
"rating": "negative",
|
||||
"ifVersion": null
|
||||
}
|
||||
}
|
||||
},
|
||||
"status": 200,
|
||||
"response": {
|
||||
"type": "server-response",
|
||||
"rpcId": "feedback-conflict",
|
||||
"result": {
|
||||
"ok": true,
|
||||
"value": {
|
||||
"ok": false,
|
||||
"error": {
|
||||
"code": "version-conflict",
|
||||
"current": {
|
||||
"messageId": "11111111-1111-4111-8111-111111111111",
|
||||
"rating": "positive",
|
||||
"note": "Useful answer",
|
||||
"version": "{{version}}",
|
||||
"createdAt": "{{timestamp}}",
|
||||
"updatedAt": "{{timestamp}}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"endpoint": "/api/messageFeedback/delete",
|
||||
"request": {
|
||||
"args": {
|
||||
"request": {
|
||||
"sessionId": "message-feedback-protocol",
|
||||
"messageId": "11111111-1111-4111-8111-111111111111",
|
||||
"ifVersion": "{{version}}"
|
||||
}
|
||||
}
|
||||
},
|
||||
"status": 200,
|
||||
"response": {
|
||||
"type": "server-response",
|
||||
"rpcId": "feedback-delete",
|
||||
"result": {
|
||||
"ok": true,
|
||||
"value": {
|
||||
"ok": true,
|
||||
"value": {
|
||||
"absent": true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"endpoint": "/api/messageFeedback/list",
|
||||
"request": {
|
||||
"args": {
|
||||
"request": {
|
||||
"sessionId": "message-feedback-protocol"
|
||||
}
|
||||
}
|
||||
},
|
||||
"status": 200,
|
||||
"response": {
|
||||
"type": "server-response",
|
||||
"rpcId": "feedback-list-deleted",
|
||||
"result": {
|
||||
"ok": true,
|
||||
"value": {
|
||||
"ok": true,
|
||||
"value": {
|
||||
"items": []
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
]
|
||||
@@ -0,0 +1,7 @@
|
||||
{"type":"session","version":0,"id":"{{sessionId}}","createdAt":1786406400000,"cwd":"{{cwd}}"}
|
||||
{"type":"turn/start","seq":0,"time":1786406400001,"data":{"turn":1}}
|
||||
{"type":"user/message","seq":1,"time":1786406400002,"data":{"role":"user","content":[{"type":"text","text":"Give one useful answer."}],"source":{"kind":"user"},"id":"22222222-2222-4222-8222-222222222222"},"surfaceOp":"append"}
|
||||
{"type":"step/start","seq":2,"time":1786406400003,"data":{"turn":1,"step":1}}
|
||||
{"type":"assistant/message","seq":3,"time":1786406400004,"data":{"turn":1,"step":1,"message":{"role":"assistant","content":[{"type":"text","text":"A useful answer."}],"source":{"kind":"model","provider":"fixture","model":"fixture"},"id":"11111111-1111-4111-8111-111111111111"},"usage":{"inputTokens":4,"outputTokens":4}},"surfaceOp":"append"}
|
||||
{"type":"step/end","seq":4,"time":1786406400005,"data":{"turn":1,"step":1}}
|
||||
{"type":"turn/end","seq":5,"time":1786406400006,"data":{"turn":1,"reason":{"kind":"completed"}}}
|
||||
@@ -38,10 +38,10 @@
|
||||
- text: Context injection AGENTS.md
|
||||
- img
|
||||
- text: permission preset read-only
|
||||
- 'button "feedback Feedback recorded for session {{seededId}} User: {{uuid}}" [expanded]':
|
||||
- 'button "feedback Feedback recorded for session {{seededId}} User: {{uuid}}. Session sharing is not configured." [expanded]':
|
||||
- img
|
||||
- text: "feedback Feedback recorded for session {{seededId}} User: {{uuid}}"
|
||||
- text: "Feedback recorded for session {{seededId}} User: {{uuid}}"
|
||||
- text: "feedback Feedback recorded for session {{seededId}} User: {{uuid}}. Session sharing is not configured."
|
||||
- text: "Feedback recorded for session {{seededId}} User: {{uuid}}. Session sharing is not configured."
|
||||
- textbox "Message the agent"
|
||||
- button "Commands":
|
||||
- img
|
||||
|
||||
@@ -395,7 +395,8 @@ describe('web e2e: persisted subagent conversation and human continuation', () =
|
||||
expect([
|
||||
Math.round(clickAreaBox!.x - treeBox!.x),
|
||||
Math.round(treeBox!.x + treeBox!.width - clickAreaBox!.x - clickAreaBox!.width),
|
||||
]).toEqual([5, 5])
|
||||
// Menu padding alone insets the rows now that the border is gone.
|
||||
]).toEqual([4, 4])
|
||||
await compareOrRefreshGolden(
|
||||
BRANCHLESS_EXPECTED,
|
||||
await captureStableAria(page, '[role="tree"][aria-label="Subagent sessions"]', scaffold.workspaceCwd),
|
||||
|
||||
@@ -57,9 +57,9 @@ export function probeFreePort(): Promise<number> {
|
||||
/**
|
||||
* Drive the hero's workspace picker through the composed directory dialog
|
||||
* until the live composer unlocks. A fresh world has no Workspace, so the boot
|
||||
* lands in the locked view state (startup auto-selection has nothing to
|
||||
* lands in the Workspace-trigger view state (startup auto-selection has nothing to
|
||||
* select); every scenario that types into the composer must connect one
|
||||
* first. With nothing to list, the chip gesture raises the dialog directly —
|
||||
* first. With nothing to list, activating the textarea raises the dialog directly —
|
||||
* adding a workspace is the picker's only entry. The directory is staged here
|
||||
* and adopted through the path editor, which is idempotent across the repeated
|
||||
* connects a scenario may make; creating a folder from inside the dialog (the
|
||||
@@ -73,7 +73,7 @@ export function probeFreePort(): Promise<number> {
|
||||
*/
|
||||
export async function connectFreshWorkspace(page: Page, root: string, name = 'workspace'): Promise<void> {
|
||||
mkdirSync(join(root, name), { recursive: true })
|
||||
await page.getByRole('button', { name: 'Choose workspace' }).click()
|
||||
await page.getByRole('textbox', { name: 'Choose workspace' }).click()
|
||||
const dialog = page.getByRole('dialog', { name: 'Select Workspace Directory' })
|
||||
await dialog.waitFor({ timeout: 10_000 })
|
||||
await dialog.getByRole('button', { name: 'Edit path' }).click()
|
||||
@@ -97,7 +97,7 @@ export async function connectFreshWorkspace(page: Page, root: string, name = 'wo
|
||||
*/
|
||||
export async function connectFreshWorkspaceZh(page: Page, root: string, name = 'workspace'): Promise<void> {
|
||||
mkdirSync(join(root, name), { recursive: true })
|
||||
await page.getByRole('button', { name: '选择工作区' }).click()
|
||||
await page.getByRole('textbox', { name: '选择工作区' }).click()
|
||||
const dialog = page.getByRole('dialog', { name: '选择工作区目录' })
|
||||
await dialog.waitFor({ timeout: 10_000 })
|
||||
await dialog.getByRole('button', { name: '编辑路径' }).click()
|
||||
|
||||
@@ -99,6 +99,19 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reveal and click a row action, re-hovering if a projection update replaces
|
||||
* the row before its hover-only button becomes visible.
|
||||
*/
|
||||
async function clickHoverAction(row: Locator, name: string): Promise<void> {
|
||||
const button = row.getByRole('button', { name })
|
||||
await expect.poll(async () => {
|
||||
await row.hover()
|
||||
return await button.isVisible()
|
||||
}, { timeout: 10_000 }).toBe(true)
|
||||
await button.click()
|
||||
}
|
||||
|
||||
beforeAll(async () => {
|
||||
scaffold = await launchWebScaffold({})
|
||||
// Seed one cold session (Ungrouped bucket) for the flat view + hover card.
|
||||
@@ -137,10 +150,8 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
|
||||
|
||||
it('renames a workspace over the wire with a duplicate-name pre-check', async () => {
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-rename'))
|
||||
// The actions button is display:none until its row hovers — hover the
|
||||
// group row first, then the revealed button becomes actionable.
|
||||
await page.locator('[role="treeitem"]').filter({ hasText: 'alpha-ws' }).first().hover()
|
||||
await page.getByRole('button', { name: 'Workspace actions for alpha-ws' }).click()
|
||||
const alphaRow = page.locator('[role="treeitem"]').filter({ hasText: 'alpha-ws' }).first()
|
||||
await clickHoverAction(alphaRow, 'Workspace actions for alpha-ws')
|
||||
await page.getByRole('menuitem', { name: 'Rename' }).click()
|
||||
const dialog = page.getByRole('dialog', { name: 'Rename workspace' })
|
||||
await dialog.waitFor({ timeout: 10_000 })
|
||||
@@ -214,17 +225,19 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
|
||||
// The header row is wrapped by its HoverCard anchor span, so the section
|
||||
// is the nearest groupSection ancestor, not the immediate parent.
|
||||
const groupSection = groupRow.locator('xpath=ancestor::*[contains(@class, "groupSection")][1]')
|
||||
if (await groupSection.locator('[role="treeitem"]').count() < 2) await groupRow.click()
|
||||
await expect.poll(
|
||||
() => groupSection.locator('[role="treeitem"]').count(),
|
||||
{ timeout: 10_000 },
|
||||
).toBeGreaterThanOrEqual(2)
|
||||
await expect.poll(async () => {
|
||||
const count = await groupSection.locator('[role="treeitem"]').count()
|
||||
if (count < 2 && await groupRow.getAttribute('aria-expanded') !== 'true') {
|
||||
await groupRow.click()
|
||||
await page.waitForTimeout(50)
|
||||
}
|
||||
return await groupSection.locator('[role="treeitem"]').count()
|
||||
}, { timeout: 10_000 }).toBeGreaterThanOrEqual(2)
|
||||
const seededRow = groupSection.locator('[role="treeitem"]').nth(1)
|
||||
await seededRow.click()
|
||||
await expect.poll(() => seededRow.getAttribute('aria-selected'), { timeout: 10_000 }).toBe('true')
|
||||
|
||||
await groupRow.hover()
|
||||
await page.getByRole('button', { name: `Workspace actions for ${workspace.title}` }).click()
|
||||
await clickHoverAction(groupRow, `Workspace actions for ${workspace.title}`)
|
||||
await page.getByRole('menuitem', { name: 'Delete workspace' }).click()
|
||||
const dialog = page.getByRole('dialog', { name: 'Delete workspace' })
|
||||
await dialog.waitFor({ timeout: 10_000 })
|
||||
@@ -338,8 +351,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
|
||||
if (oldWorkspace === undefined) throw new Error('old same-name Workspace was not registered')
|
||||
|
||||
const oldRow = page.locator('[role="treeitem"]').filter({ hasText: title }).first()
|
||||
await oldRow.hover()
|
||||
await page.getByRole('button', { name: `Workspace actions for ${title}` }).click()
|
||||
await clickHoverAction(oldRow, `Workspace actions for ${title}`)
|
||||
await page.getByRole('menuitem', { name: 'Delete workspace' }).click()
|
||||
await page.getByRole('dialog', { name: 'Delete workspace' })
|
||||
.getByRole('button', { name: 'Delete workspace' }).click()
|
||||
@@ -509,9 +521,10 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
|
||||
onTestFailed(() => saveFailureShot(page, 'web-e2e-ws-row-menu'))
|
||||
const sessionRow = await seededSessionRow()
|
||||
// The trigger is display:none until its row hovers.
|
||||
await sessionRow.hover()
|
||||
const trigger = sessionRow.locator('button[aria-label^="Session actions for "]')
|
||||
await trigger.click()
|
||||
const triggerName = await trigger.getAttribute('aria-label')
|
||||
if (triggerName === null) throw new Error('seeded Session row has no actions label')
|
||||
await clickHoverAction(sessionRow, triggerName)
|
||||
const item = page.getByRole('menuitem', { name: 'Rename' })
|
||||
await item.waitFor({ timeout: 5_000 })
|
||||
// Into the list, then back up to the trigger across the 4px gap below it:
|
||||
@@ -559,8 +572,7 @@ describe('web e2e: workspace management (create / rename / flat view / hover aff
|
||||
const rowTitle = await sessionRow.locator('[class*="title"]').innerText()
|
||||
// Row menu: hover reveals the actions button; Archive session commits
|
||||
// without a confirmation dialog (non-destructive: log + accounting stay).
|
||||
await sessionRow.hover()
|
||||
await sessionRow.getByRole('button', { name: `Session actions for ${rowTitle}` }).click()
|
||||
await clickHoverAction(sessionRow, `Session actions for ${rowTitle}`)
|
||||
await page.getByRole('menuitem', { name: 'Archive session' }).click()
|
||||
// The row disappears on the archive-set echo; with no other visible
|
||||
// stray, the whole Ungrouped bucket withdraws.
|
||||
|
||||
@@ -25,6 +25,7 @@
|
||||
"tests/scaffold.ts",
|
||||
"tests/scaffold-hermetic.e2e.ts",
|
||||
"tests/minimal-preset.snapshot.ts",
|
||||
"tests/message-feedback-protocol.snapshot.ts",
|
||||
"tests/live-interactions.e2e.ts",
|
||||
"tests/question-composer.e2e.ts",
|
||||
"tests/approval-composer.e2e.ts",
|
||||
@@ -64,6 +65,7 @@
|
||||
"tests/agent-preset-selection.e2e.ts",
|
||||
"tests/agent-preset-authoring.e2e.ts",
|
||||
"tests/shipped-composition.e2e.ts",
|
||||
"tests/feedback-command.e2e.ts",
|
||||
"tests/startup-auto-selection.e2e.ts",
|
||||
"tests/produced-files.e2e.ts",
|
||||
"tests/produced-file-mentions.e2e.ts",
|
||||
@@ -72,6 +74,7 @@
|
||||
"tests/subagent-interrupt.e2e.ts",
|
||||
"tests/subagent-interrupt-ui.e2e.ts",
|
||||
"tests/sidebar-subagent-activity.e2e.ts",
|
||||
"tests/background-task-list.e2e.ts",
|
||||
"tests/bash-abort-row.e2e.ts",
|
||||
"tests/skill-tool-row.e2e.ts",
|
||||
"tests/turn-tail-actions.e2e.ts",
|
||||
|
||||
@@ -5,8 +5,8 @@ import react from '@vitejs/plugin-react'
|
||||
|
||||
const src = (rel: string): string => fileURLToPath(new URL(rel, import.meta.url))
|
||||
const STANDALONE_ERROR = 'apps/web is not a standalone application: bare Vite cannot inject window.__DSH_BOOT__. '
|
||||
+ 'Build with `pnpm run build && pnpm run build:web`, then run `dsh web` (repository checkout: `pnpm run dsh -- web`). '
|
||||
+ 'For client-plugin HMR, run `pnpm run dsh -- web --dev` together with `pnpm run dev:web`.'
|
||||
+ 'From a repository checkout, run `pnpm dsh web`; an installed package uses `dsh web`. '
|
||||
+ 'For client-plugin HMR, run `pnpm dsh web --dev` together with `pnpm run dev:web`.'
|
||||
|
||||
/** Fail before a Vite dev or preview server can expose the boot-manifest-free shell. */
|
||||
function rejectStandaloneServe(): Plugin {
|
||||
|
||||
Reference in New Issue
Block a user