Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
14 commits
Select commit Hold shift + click to select a range
fb00436
feat(devtools): 小程序开发页改为独立窗口,可同时开多个项目
lbb00 Sep 3, 2026
79aa149
Merge branch 'main' into feat/standalone-project-window
lbb00 Sep 3, 2026
4fa64d1
ci: 让基线守卫在 PR 上能看见本 PR 自己的提交
lbb00 Sep 3, 2026
e6efbf6
test(runtime): 补齐 service→container 路由在运行时层的回归用例
lbb00 Sep 3, 2026
f8ff4ef
fix(devkit,devtools): 编译产物按项目路径隔离,同一目录不同写法只开一个窗口
lbb00 Sep 4, 2026
ca950a1
fix(devtools): 当前项目只由窗口焦点决定,MCP 每次调用锁定同一个项目窗口
lbb00 Sep 4, 2026
f7727b2
fix(devtools): 窗口管理器 disposeAll 后不再开新窗口,并等在途打开落定
lbb00 Sep 4, 2026
a22fa98
fix(devtools): MCP 的 CDP 连接只认项目窗口自己的页面,切窗期间不把旧连接交出去
lbb00 Sep 4, 2026
98f61f7
feat(devtools): 项目窗口有自己的宿主钩子 setupProjectWindow,打开要等 onSetup 完成
lbb00 Sep 4, 2026
e5e42bf
fix(devtools): 回到项目列表和更新弹窗统一归列表窗口自己处理
lbb00 Sep 4, 2026
c5fa03b
fix(devtools): 项目窗口等 setupProjectWindow 完成再显示,宿主自己打开项目不再等 onSetup
lbb00 Sep 4, 2026
6c7c0cb
fix(devtools): MCP 模拟器目标先按窗口归属再选,日志缓冲随窗口清空,overview 不跨窗口混数据
lbb00 Sep 4, 2026
0a73156
fix(devtools): 钩子期间收到关闭或 disposeAll 的项目窗口不再显示,overview 汇总与连接同一刻取值
lbb00 Sep 4, 2026
d8753fd
fix(devtools): 项目窗口被失败的 open 拆掉后,排队的关闭不再对它重跑 onBeforeClose
lbb00 Sep 4, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
15 changes: 15 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -203,8 +203,23 @@ jobs:
command: check
comment: 'false'

# `pawl guard` reads `Pawl-Accept:` trailers from the commits between the
# base and HEAD, which is how a deliberately accepted regression (a
# changed measurement definition, say) is authorized. actions/checkout
# fetches refs/pull/N/merge at depth 1, so HEAD is a grafted merge commit
# with no parents: the only commit in range is the merge commit GitHub
# generated, whose message carries no trailer, and an authorized
# regression is reported as unauthorized. Deepening past this PR's own
# commits puts them back in range.
- name: Anti-regression baseline guard
if: github.event_name == 'pull_request'
env:
PR_COMMITS: ${{ github.event.pull_request.commits }}
MERGE_REF: refs/pull/${{ github.event.pull_request.number }}/merge
run: |
set -euo pipefail
if [ "$(git rev-parse --is-shallow-repository)" = "true" ]; then
git fetch --deepen="$((PR_COMMITS + 1))" origin "$MERGE_REF"
fi
git fetch --depth=1 origin "$GITHUB_BASE_REF"
pawl guard FETCH_HEAD
3 changes: 2 additions & 1 deletion packages/devkit/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,8 @@ await session.close()
| `fileTypes` | — | 在内置 `wx*`、`dd*` 文件类型之外追加模板、样式和视图脚本扩展名 |
| `simulatorDir` | — | 提供后启用 `/simulator` 静态资源路由 |
| `containerDir` | 包内置容器 | 覆盖 H5 容器静态资源目录 |
| `outputDir` | 系统临时目录中的项目哈希路径 | 覆盖编译产物目录 |
| `outputDir` | 系统临时目录中的项目哈希路径 | 最终编译产物目录,与 `outputRoot` 互斥 |
| `outputRoot` | — | 产物目录的父目录;devkit 在其下按 `sha1(resolve(projectPath)).slice(0, 12)` 建子目录,与 `outputDir` 互斥、同传会报错 |
| `watch` | `true` | 是否监听文件并自动重新编译 |
| `autoReload` | `true` | watcher 编译成功后是否刷新预览;纯样式改动会走样式热更新 |
| `onRebuild` | — | 重新编译成功后的回调,参数包含 `changedPaths`、`styleOnly`,显式 `session.rebuild()` 还会带 `explicit: true` |
Expand Down
19 changes: 18 additions & 1 deletion packages/devkit/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,7 +137,16 @@ export interface OpenProjectOptions {
fileTypes?: { template?: string[]; style?: string[]; viewScript?: string[] }
simulatorDir?: string
containerDir?: string
/** Final artifact directory, verbatim. Mutually exclusive with `outputRoot`. */
outputDir?: string
/**
* Parent directory for artifacts. devkit derives the actual output
* directory as `outputRoot/sha1(resolve(projectPath)).slice(0, 12)`, keyed
* by the resolved project path — so two different projects never collide
* under the same root even when they report the same appid. Mutually
* exclusive with `outputDir`; passing both is rejected.
*/
outputRoot?: string
/** When false, skip the chokidar file-watcher / auto-rebuild loop. Default true. */
watch?: boolean
/**
Expand Down Expand Up @@ -242,6 +251,7 @@ export async function openProject(opts: OpenProjectOptions): Promise<ProjectSess
simulatorDir,
containerDir: overrideContainerDir,
outputDir,
outputRoot,
watch = true,
autoReload = true,
onRebuild,
Expand All @@ -252,11 +262,18 @@ export async function openProject(opts: OpenProjectOptions): Promise<ProjectSess
const projectPath = path.resolve(rawProjectPath)
const buildOptions = { sourcemap, fileTypes }

if (outputDir !== undefined && outputRoot !== undefined) {
throw new Error('[devkit] openProject: pass either "outputDir" or "outputRoot", not both')
}

const resolvedPort = port === 0 ? await getRandomPort() : port

const containerDir = overrideContainerDir ?? path.join(__dirname, '..', 'fe', 'dimina-fe-container')
const resolvedOutputDir = outputDir
?? path.join(os.tmpdir(), 'dimina-kit', createHash('sha1').update(projectPath).digest('hex').slice(0, 12))
?? path.join(
outputRoot ?? path.join(os.tmpdir(), 'dimina-kit'),
createHash('sha1').update(projectPath).digest('hex').slice(0, 12),
)
fs.mkdirSync(resolvedOutputDir, { recursive: true })

// Compilation runs in a long-lived forked worker — the worker chdirs in
Expand Down
212 changes: 212 additions & 0 deletions packages/devkit/src/open-project-output-root.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,212 @@
import { EventEmitter } from 'node:events'
import fs from 'node:fs'
import os from 'node:os'
import path from 'node:path'
import { createHash } from 'node:crypto'
import { PassThrough } from 'node:stream'
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
import * as devkit from './index.js'

/**
* Contract: `openProject()` gains an optional `outputRoot`.
*
* - When `outputRoot` is passed (and `outputDir` is not), the artifact
* directory is `path.join(outputRoot, sha1(path.resolve(projectPath)).slice(0, 12))`
* — keyed by the RESOLVED project path, so two different project paths
* never collide under the same root even when their `project.config.json`
* reports the same appid.
* - Passing `outputDir` still selects the final directory verbatim — that
* existing contract is unchanged by the new option.
* - Passing both `outputDir` AND `outputRoot` together is ambiguous and must
* reject, with both option names named in the error.
*
* Harness copied from open-project-rebuild.test.ts: fake fork + fake fe +
* fake chokidar, `watch: false` throughout so no watcher mock is exercised.
*/

const mocks = vi.hoisted(() => ({
fork: vi.fn(),
feStart: vi.fn(),
watch: vi.fn(),
}))

vi.mock('node:child_process', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:child_process')>()
return { ...actual, fork: mocks.fork, default: { ...actual, fork: mocks.fork } }
})
vi.mock('child_process', async (importOriginal) => {
const actual = await importOriginal<typeof import('node:child_process')>()
return { ...actual, fork: mocks.fork, default: { ...actual, fork: mocks.fork } }
})
vi.mock('../fe/index.js', () => ({ start: mocks.feStart }))
vi.mock('chokidar', () => ({
default: { watch: mocks.watch },
watch: mocks.watch,
}))

class FakeChild extends EventEmitter {
stdout = new PassThrough()
stderr = new PassThrough()
connected = true
pid = 7171
send = vi.fn((msg: unknown): boolean => {
const m = msg as Record<string, unknown>
if (m && m.cmd === 'build') {
const outputDir = String(m.outputDir ?? '')
queueMicrotask(() => this.emit('message', {
type: 'result',
appInfo: { appId: 'outputroot_app', name: 'outputroot-app', path: outputDir },
}))
}
return true
})

kill = vi.fn((): boolean => {
this.connected = false
queueMicrotask(() => this.emit('exit', null, 'SIGTERM'))
return true
})
}

interface FakeFe {
server: { close: ReturnType<typeof vi.fn>, closeAllConnections: ReturnType<typeof vi.fn> }
reload: ReturnType<typeof vi.fn>
}

function makeFakeFe(): FakeFe {
const server = {
close: vi.fn((cb?: () => void) => { cb?.(); return server }),
closeAllConnections: vi.fn(),
}
return { server, reload: vi.fn() }
}

const children: FakeChild[] = []
const feInstances: FakeFe[] = []
const cleanupRoots: string[] = []

beforeEach(() => {
children.length = 0
feInstances.length = 0
mocks.fork.mockReset()
mocks.fork.mockImplementation(() => {
const child = new FakeChild()
children.push(child)
return child
})
mocks.feStart.mockReset()
mocks.feStart.mockImplementation(async () => {
const fe = makeFakeFe()
feInstances.push(fe)
return fe
})
mocks.watch.mockReset()
})

afterEach(() => {
for (const root of cleanupRoots.splice(0)) {
fs.rmSync(root, { recursive: true, force: true })
}
})

function makeFixture(appid = 'outputroot_app'): string {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'devkit-outputroot-'))
cleanupRoots.push(root)
fs.writeFileSync(
path.join(root, 'project.config.json'),
JSON.stringify({ appid, projectname: 'outputroot-app' }),
)
return root
}

function lastBuildOutputDir(child: FakeChild): string {
const buildCalls = child.send.mock.calls.filter((c) => {
const m = c[0] as Record<string, unknown>
return m?.cmd === 'build'
})
const msg = buildCalls[buildCalls.length - 1]?.[0] as Record<string, unknown> | undefined
return String(msg?.outputDir ?? '')
}

function expectedSubdir(projectPath: string): string {
return createHash('sha1').update(path.resolve(projectPath)).digest('hex').slice(0, 12)
}

describe('openProject({ outputRoot }): artifact directory derived from outputRoot + hashed project path', () => {
it('places artifacts under outputRoot/sha1(resolvedProjectPath).slice(0, 12)', async () => {
const root = makeFixture()
const outputRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'devkit-outputroot-target-'))
cleanupRoots.push(outputRoot)

const session = await devkit.openProject({
projectPath: root,
watch: false,
outputRoot,
} as devkit.OpenProjectOptions & { outputRoot: string })

const expected = path.join(outputRoot, expectedSubdir(root))
expect(
lastBuildOutputDir(children[0]!),
'outputRoot must be honored the same way the tmpdir default is today (index.ts:258) instead of being silently ignored',
).toBe(expected)

await session.close()
}, 15_000)

it('two different project paths sharing the same appid land in different subdirectories under outputRoot', async () => {
const rootA = makeFixture('same_appid')
const rootB = makeFixture('same_appid')
const outputRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'devkit-outputroot-collide-'))
cleanupRoots.push(outputRoot)

const sessionA = await devkit.openProject({
projectPath: rootA,
watch: false,
outputRoot,
} as devkit.OpenProjectOptions & { outputRoot: string })
const dirA = lastBuildOutputDir(children[0]!)

const sessionB = await devkit.openProject({
projectPath: rootB,
watch: false,
outputRoot,
} as devkit.OpenProjectOptions & { outputRoot: string })
const dirB = lastBuildOutputDir(children[1]!)

expect(dirA.startsWith(outputRoot), 'project A must build under the requested outputRoot').toBe(true)
expect(dirB.startsWith(outputRoot), 'project B must build under the requested outputRoot').toBe(true)
expect(
dirA,
'same appid, different project paths — the two builds must not be keyed onto the same output directory',
).not.toBe(dirB)

await sessionA.close()
await sessionB.close()
}, 15_000)

it('rejects when both outputDir and outputRoot are passed, naming both options in the error', async () => {
const root = makeFixture()
const outputDir = path.join(root, '.out')
const outputRoot = fs.mkdtempSync(path.join(os.tmpdir(), 'devkit-outputroot-conflict-'))
cleanupRoots.push(outputRoot)

await expect(
devkit.openProject({
projectPath: root,
watch: false,
outputDir,
outputRoot,
} as devkit.OpenProjectOptions & { outputRoot: string }),
'passing both options is ambiguous about which one wins and must fail loudly instead of silently picking outputDir',
).rejects.toThrow(/outputDir/i)

await expect(
devkit.openProject({
projectPath: root,
watch: false,
outputDir,
outputRoot,
} as devkit.OpenProjectOptions & { outputRoot: string }),
).rejects.toThrow(/outputRoot/i)
}, 15_000)
})
23 changes: 21 additions & 2 deletions packages/devtools/docs/host-migration.md
Original file line number Diff line number Diff line change
Expand Up @@ -125,7 +125,7 @@ launch({

### 8. `window.autoShow` 开关

`WorkbenchAppConfig.window` 的 `autoShow?: boolean`(默认 `true`)控制 `ready-to-show` 是否自动显示主窗口。要先过登录门再显示窗口的宿主设 `autoShow: false`,自己在登录通过后 show:
`WorkbenchAppConfig.window` 的 `autoShow?: boolean`(默认 `true`)控制 `ready-to-show` 是否自动显示**项目列表窗口**。要先过登录门再显示窗口的宿主设 `autoShow: false`,自己在登录通过后 show:

```ts
launch({
Expand All @@ -143,7 +143,26 @@ launch({

宿主在 test 下独占 reveal,无需写防御性 `on('show', hide)` re-hide——窗口按宿主自己的节奏 show 后常规 `waitForFunction` 即可。

### 9. e2e 识别主窗口(用 `window.devtools` 标识)
`window.autoShow` **只管列表窗口**。项目窗口有自己的开关 `projectWindow.autoShow`(同样默认 `true`):登录门把列表窗口藏起来时,之后打开的项目窗口照常显示,不再跟着一起隐藏。要接管项目窗口的显示时机才设 `projectWindow: { autoShow: false }`,然后自己在 `setupProjectWindow(instance, opened)` 里 `opened.window.show()`。

### 9. 项目窗口的宿主注册用 `setupProjectWindow`

`onSetup(instance)` 只在启动时跑一次,`instance.context` 是**项目列表窗口**的 context——它不持有会话和视图,在它上面做的项目相关注册进不了任何项目窗口。项目级注册改用 `setupProjectWindow`,每打开一个项目窗口调用一次:

```ts
launch({
async setupProjectWindow(instance, opened) {
// opened: { path, name?, window, context } —— context 是这个项目窗口自己的
await opened.context.views.hostToolbar.loadFile(toolbarHtml)
},
})
```

- `opened` 的形状(`ProjectWindowRef`)与 `onBeforeClose` 收到的 `closing` 一致,一进一出对称;
- hook 被 await,且**能否决这次打开**:抛错时框架拆掉这个半成品窗口(销毁 window、dispose context、从 `projectWindows()` 里摘掉),并把原始错误抛给发起打开的一方——这点和 `onBeforeClose` 不同,后者的错误只记日志;
- `onSetup` 还没跑完时到达的打开请求(渲染进程可以抢在宿主扩展就绪前触发打开)会先排队等它,不会拿到一个宿主尚未扩展的窗口。

### 10. e2e 识别主窗口(用 `window.devtools` 标识)

写 Playwright e2e 选主窗口用框架已注入的 `window.devtools`(主窗口 preload 暴露的 IPC bridge,见 `preload/windows/main.ts`)作稳定选择契约——它是**主窗口独有**的(host-toolbar 暴露的是 `window.diminaHostToolbar`),与展示名解耦、抗时序、抗 WCV 增多。不要用 `electronApp.firstWindow()`(依赖创建顺序,`autoShow:false` 下会抢到 host-toolbar WCV)或 `url().endsWith('index.html')` / title(依赖 renderer 入口路径、会被 `appName` / `brandingProvider` 改写)。e2e 选主窗口:

Expand Down
26 changes: 24 additions & 2 deletions packages/devtools/docs/library-integration.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,10 +66,12 @@ launch({
| --- | --- |
| `rendererDir` | 覆盖内置 renderer 目录 |
| `modules` | 按 `projects`、`session`、`simulator`、`popover`、`settings` 开关内置 IPC 模块组 |
| `window` | 主窗口尺寸和 `autoShow` 设置 |
| `window` | 项目列表窗口的尺寸和 `autoShow` 设置 |
| `projectWindow` | 项目窗口的 `autoShow` 设置,默认 `true`,与 `window` 互不影响 |
| `icon` | 窗口或任务栏图标 |
| `menuBuilder` | 安装宿主菜单;参数是主窗口和窄化后的 `MenuContext` |
| `onSetup` | 窗口与 context 建好后注册宿主扩展,可返回 Promise |
| `onSetup` | 项目列表窗口与 context 建好后注册应用级宿主扩展,可返回 Promise |
| `setupProjectWindow` | 每打开一个项目窗口调用一次,拿到这个窗口自己的 context 做项目级注册;抛错等于本次打开失败 |
| `onBeforeClose` | 有活动会话时,在自动关闭会话前运行宿主清理,可返回 Promise |
| `onBeforeOpenProject` | 任何打开项目副作用发生前执行;抛错会拒绝本次打开并保留当前会话 |
| `editorViewConfig` | 覆盖 VS Code 工作台 bundle,或提供 web extensions 目录 |
Expand Down Expand Up @@ -157,6 +159,26 @@ onSetup(instance) {

这些注册都归当前 context 所有,并在 context 销毁时清理。`registerTrustedWindow()` 返回的对象也可以提前 `dispose()`。

### 项目级注册用 `setupProjectWindow`

`onSetup(instance)` 里的 `instance.context` 永远是项目列表窗口的 context,它不持有任何会话和视图。要给具体某个项目窗口注册东西(面板、host toolbar、按 context 分的 IPC 状态),用 `setupProjectWindow`:

```ts
launch({
onSetup(instance) {
// 应用级:整个进程注册一次就够
instance.registerSimulatorApi('share', params => share(params))
},
async setupProjectWindow(instance, opened) {
// 项目级:opened.context 是这个项目窗口自己的 context
await opened.context.views.hostToolbar.loadFile(toolbarHtml)
console.log('打开了', opened.path, opened.name)
},
})
```

`opened` 的形状是 `ProjectWindowRef`(`path`、`name?`、`window`、`context`),和 `onBeforeClose` 收到的 `closing` 一致。hook 会被 await,抛错等于这次打开失败:框架拆掉这个半成品窗口,原样把错误抛给发起打开的一方。`onSetup` 尚未完成时到达的打开请求会先等它,不会拿到一个宿主还没扩展完的窗口。

### Simulator 自定义 API

`registerSimulatorApi(name, handler)` 让小程序代码通过 `wx.<name>()` 调用 handler。handler 可以同步返回或返回 Promise;参数和返回值必须能通过 Electron IPC 序列化。返回的 disposer 只删除本次注册,如果同名 API 后来已被覆盖,不会误删新 handler。
Expand Down
8 changes: 4 additions & 4 deletions packages/devtools/e2e/appdata-edit.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,18 +7,18 @@ test.describe('AppData Panel Edit Write-Back', () => {

useSharedProject(test, DEMO_APP_DIR)

test('editing a value in the tree round-trips through service→render setData and re-renders from the pushed snapshot', async ({ mainWindow }) => {
await mainWindow.getByRole('tab', { name: 'AppData' }).click()
test('editing a value in the tree round-trips through service→render setData and re-renders from the pushed snapshot', async ({ workbench }) => {
await workbench.getByRole('tab', { name: 'AppData' }).click()

// Pages sidebar lists the running page; the demo app's first page is
// pages/index/index and its bridge auto-activates (useActiveBridgeId
// follows the simulator's active page path), so the data tree for it is
// already the visible one once data arrives.
const pages = mainWindow.getByTestId('appdata-pages')
const pages = workbench.getByTestId('appdata-pages')
await expect(pages).toBeVisible({ timeout: 30_000 })
await expect(pages).toContainText('pages/index/index', { timeout: 30_000 })

const tree = mainWindow.getByTestId('appdata-tree')
const tree = workbench.getByTestId('appdata-tree')
await expect(tree).toBeVisible({ timeout: 30_000 })

// The root row starts expanded but its children start collapsed; open
Expand Down
Loading
Loading