diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 08c657b6..783d1755 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -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 diff --git a/packages/devkit/README.md b/packages/devkit/README.md index d589586b..cff4647d 100644 --- a/packages/devkit/README.md +++ b/packages/devkit/README.md @@ -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` | diff --git a/packages/devkit/src/index.ts b/packages/devkit/src/index.ts index ddcd79ae..2d2884cb 100644 --- a/packages/devkit/src/index.ts +++ b/packages/devkit/src/index.ts @@ -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 /** @@ -242,6 +251,7 @@ export async function openProject(opts: OpenProjectOptions): Promise ({ + fork: vi.fn(), + feStart: vi.fn(), + watch: vi.fn(), +})) + +vi.mock('node:child_process', async (importOriginal) => { + const actual = await importOriginal() + return { ...actual, fork: mocks.fork, default: { ...actual, fork: mocks.fork } } +}) +vi.mock('child_process', async (importOriginal) => { + const actual = await importOriginal() + 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 + 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, closeAllConnections: ReturnType } + reload: ReturnType +} + +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 + return m?.cmd === 'build' + }) + const msg = buildCalls[buildCalls.length - 1]?.[0] as Record | 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) +}) diff --git a/packages/devtools/docs/host-migration.md b/packages/devtools/docs/host-migration.md index 469377f7..88ec6ded 100644 --- a/packages/devtools/docs/host-migration.md +++ b/packages/devtools/docs/host-migration.md @@ -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({ @@ -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 选主窗口: diff --git a/packages/devtools/docs/library-integration.md b/packages/devtools/docs/library-integration.md index c5febe72..6056d532 100644 --- a/packages/devtools/docs/library-integration.md +++ b/packages/devtools/docs/library-integration.md @@ -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 目录 | @@ -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.()` 调用 handler。handler 可以同步返回或返回 Promise;参数和返回值必须能通过 Electron IPC 序列化。返回的 disposer 只删除本次注册,如果同名 API 后来已被覆盖,不会误删新 handler。 diff --git a/packages/devtools/e2e/appdata-edit.spec.ts b/packages/devtools/e2e/appdata-edit.spec.ts index c55d2dc4..594be29b 100644 --- a/packages/devtools/e2e/appdata-edit.spec.ts +++ b/packages/devtools/e2e/appdata-edit.spec.ts @@ -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 diff --git a/packages/devtools/e2e/automator-compat.spec.ts b/packages/devtools/e2e/automator-compat.spec.ts index 049acea3..3fdc5cc4 100644 --- a/packages/devtools/e2e/automator-compat.spec.ts +++ b/packages/devtools/e2e/automator-compat.spec.ts @@ -117,7 +117,9 @@ test.describe('miniprogram-automator protocol compatibility', () => { // The following tests open a project first test.describe('with project open', () => { test.beforeAll(async () => { - await openProjectInUI(mainWindow, DEMO_APP_DIR, { waitMs: 8000 }) + // Return value not captured — the rest of this block drives the app + // purely over the automation-protocol WebSocket, never a Page object. + await openProjectInUI(electronApp, DEMO_APP_DIR, { waitMs: 8000 }) await waitForSimulatorWebview(electronApp) // NATIVE-HOST readiness gate. The page DOM is no longer in a same-document @@ -149,7 +151,7 @@ test.describe('miniprogram-automator protocol compatibility', () => { }) test.afterAll(async () => { - await closeProject(mainWindow).catch(() => {}) + await closeProject(electronApp).catch(() => {}) }) test('App.getPageStack returns stack', async () => { @@ -269,7 +271,10 @@ test.describe('npm miniprogram-automator package', () => { 100, ) as number - await openProjectInUI(smokeMainWindow, DEMO_APP_DIR, { waitMs: 8000 }) + // Return value not captured — this block hands off to the npm + // `miniprogram-automator` package's own `automator.connect(wsEndpoint)`, + // never a Page object of ours. + await openProjectInUI(smokeElectronApp, DEMO_APP_DIR, { waitMs: 8000 }) await waitForSimulatorWebview(smokeElectronApp) await new Promise((r) => setTimeout(r, 2000)) @@ -282,7 +287,7 @@ test.describe('npm miniprogram-automator package', () => { if (miniProgram) { miniProgram.disconnect() } - await closeProject(smokeMainWindow).catch(() => {}) + await closeProject(smokeElectronApp).catch(() => {}) await smokeElectronApp?.close().catch(() => {}) }) diff --git a/packages/devtools/e2e/automator-label-semantics.spec.ts b/packages/devtools/e2e/automator-label-semantics.spec.ts index bac15d28..eeaccc2e 100644 --- a/packages/devtools/e2e/automator-label-semantics.spec.ts +++ b/packages/devtools/e2e/automator-label-semantics.spec.ts @@ -248,7 +248,11 @@ test.beforeAll(async () => { 100, ) as number - await openProjectInUI(mainWindow, PROBE_APP_DIR, { waitMs: 8000 }) + // Return value not captured — everything after this drives the probe purely + // through the automation-protocol WebSocket (`miniProgram`/`page`) or scans + // `electronApp`'s webContents by URL marker (`trustedClick`), never the + // workbench window's own Page. + await openProjectInUI(electronApp, PROBE_APP_DIR, { waitMs: 8000 }) await waitForSimulatorWebview(electronApp) miniProgram = await automator.connect({ wsEndpoint: `ws://127.0.0.1:${autoPort}` }) @@ -268,7 +272,7 @@ test.beforeAll(async () => { test.afterAll(async () => { miniProgram?.disconnect() - await closeProject(mainWindow).catch(() => {}) + await closeProject(electronApp).catch(() => {}) await electronApp?.close().catch(() => {}) }) diff --git a/packages/devtools/e2e/automator/automator.ts b/packages/devtools/e2e/automator/automator.ts index 349fa79e..457f1da6 100644 --- a/packages/devtools/e2e/automator/automator.ts +++ b/packages/devtools/e2e/automator/automator.ts @@ -12,7 +12,7 @@ import fs from 'fs' import path from 'path' import { fileURLToPath } from 'url' import { MiniProgram } from './mini-program' -import { openProjectInUI, waitForSimulatorWebview } from '../helpers' +import { findMainWindow, openProjectInUI, waitForSimulatorWebview } from '../helpers' const __dirname = path.dirname(fileURLToPath(import.meta.url)) @@ -73,9 +73,10 @@ export class Automator { }, }) - // Wait for the main window - const mainWindow = await electronApp.firstWindow() - await mainWindow.waitForLoadState('domcontentloaded') + // Wait for the project-list window before we can move it off-screen and + // open a project into its own workbench window. + const listWindow = await findMainWindow(electronApp) + await listWindow.waitForLoadState('domcontentloaded') // Move off-screen so it doesn't steal focus await electronApp.evaluate(async ({ BrowserWindow }) => { @@ -92,8 +93,8 @@ export class Automator { } }) - // Open the project - await openProjectInUI(mainWindow, projectPath, { + // Open the project — this opens (and returns) the project's own workbench window. + const workbench = await openProjectInUI(electronApp, projectPath, { waitMs: compileWaitMs, }) @@ -101,18 +102,18 @@ export class Automator { await waitForSimulatorWebview(electronApp) } - return new MiniProgram(electronApp, mainWindow, projectPath) + return new MiniProgram(electronApp, workbench, projectPath) } /** * Connect to an already-running devtools instance. - * Requires electronApp and mainWindow from Playwright fixtures. + * Requires electronApp and the project's workbench window from Playwright fixtures. */ static connect( electronApp: ElectronApplication, - mainWindow: PwPage, + workbench: PwPage, projectPath: string, ): MiniProgram { - return new MiniProgram(electronApp, mainWindow, projectPath) + return new MiniProgram(electronApp, workbench, projectPath) } } diff --git a/packages/devtools/e2e/automator/element.ts b/packages/devtools/e2e/automator/element.ts index 44ea4220..ba86bba7 100644 --- a/packages/devtools/e2e/automator/element.ts +++ b/packages/devtools/e2e/automator/element.ts @@ -26,18 +26,19 @@ function inIframe(expression: string): string { export class Element { readonly electronApp: ElectronApplication - readonly mainWindow: PwPage + /** The project's own workbench window (each open project gets one). */ + readonly workbench: PwPage readonly selector: string readonly index: number constructor( electronApp: ElectronApplication, - mainWindow: PwPage, + workbench: PwPage, selector: string, index: number, ) { this.electronApp = electronApp - this.mainWindow = mainWindow + this.workbench = workbench this.selector = selector this.index = index } @@ -238,7 +239,7 @@ export class Element { ) if (!exists) return null const combinedSelector = `${this.selector} ${childSelector}` - return new Element(this.electronApp, this.mainWindow, combinedSelector, 0) + return new Element(this.electronApp, this.workbench, combinedSelector, 0) } /** Find all child elements matching a selector. */ @@ -254,7 +255,7 @@ export class Element { const combinedSelector = `${this.selector} ${childSelector}` const elements: Element[] = [] for (let i = 0; i < count; i++) { - elements.push(new Element(this.electronApp, this.mainWindow, combinedSelector, i)) + elements.push(new Element(this.electronApp, this.workbench, combinedSelector, i)) } return elements } diff --git a/packages/devtools/e2e/automator/mini-program.ts b/packages/devtools/e2e/automator/mini-program.ts index d8eb9525..7e05caaf 100644 --- a/packages/devtools/e2e/automator/mini-program.ts +++ b/packages/devtools/e2e/automator/mini-program.ts @@ -20,16 +20,17 @@ import { export class MiniProgram { readonly electronApp: ElectronApplication - readonly mainWindow: PwPage + /** The project's own workbench window (each open project gets one). */ + readonly workbench: PwPage readonly projectPath: string constructor( electronApp: ElectronApplication, - mainWindow: PwPage, + workbench: PwPage, projectPath: string, ) { this.electronApp = electronApp - this.mainWindow = mainWindow + this.workbench = workbench this.projectPath = projectPath } @@ -51,7 +52,7 @@ export class MiniProgram { /** Get the current active page. */ async currentPage(): Promise { const pagePath = await this.currentPagePath() - return new Page(this.electronApp, this.mainWindow, pagePath) + return new Page(this.electronApp, this.workbench, pagePath) } /** @@ -179,7 +180,7 @@ export class MiniProgram { ) // Final settle for late wx:for / setData renders inside the new page - await this.mainWindow.waitForTimeout(500) + await this.workbench.waitForTimeout(500) return this.currentPage() } @@ -215,7 +216,7 @@ export class MiniProgram { ) // Wait for content to render - await this.mainWindow.waitForTimeout(2000) + await this.workbench.waitForTimeout(2000) return this.currentPage() } @@ -227,7 +228,7 @@ export class MiniProgram { /** Navigate back by going to history.back(). */ async navigateBack(): Promise { await evalInSimulator(this.electronApp, `history.back()`) - await this.mainWindow.waitForTimeout(1500) + await this.workbench.waitForTimeout(1500) return this.currentPage() } @@ -325,7 +326,7 @@ export class MiniProgram { /** Invoke a devtools IPC handler from the renderer process. */ async ipcInvoke(channel: string, ...args: unknown[]): Promise { - return ipcInvoke(this.mainWindow, channel, ...args) + return ipcInvoke(this.workbench, channel, ...args) } // ── Screenshot ────────────────────────────────────────────────────── @@ -346,12 +347,12 @@ export class MiniProgram { /** Wait for a specific amount of time. */ async waitFor(ms: number): Promise { - await this.mainWindow.waitForTimeout(ms) + await this.workbench.waitForTimeout(ms) } /** Close the mini program and the devtools app. */ async close(): Promise { - await closeProject(this.mainWindow).catch(() => {}) + await closeProject(this.electronApp, { projectDir: this.projectPath }).catch(() => {}) await this.electronApp.close().catch(() => {}) } } diff --git a/packages/devtools/e2e/automator/page.ts b/packages/devtools/e2e/automator/page.ts index e58f18e0..8c2bbc05 100644 --- a/packages/devtools/e2e/automator/page.ts +++ b/packages/devtools/e2e/automator/page.ts @@ -30,16 +30,17 @@ function inIframe(expression: string): string { export class Page { readonly electronApp: ElectronApplication - readonly mainWindow: PwPage + /** The project's own workbench window (each open project gets one). */ + readonly workbench: PwPage readonly path: string constructor( electronApp: ElectronApplication, - mainWindow: PwPage, + workbench: PwPage, path: string, ) { this.electronApp = electronApp - this.mainWindow = mainWindow + this.workbench = workbench this.path = path } @@ -56,7 +57,7 @@ export class Page { inIframe(`return _doc.querySelector('${escaped}') !== null`), ) if (!exists) return null - return new Element(this.electronApp, this.mainWindow, selector, 0) + return new Element(this.electronApp, this.workbench, selector, 0) } /** @@ -70,7 +71,7 @@ export class Page { ) const elements: Element[] = [] for (let i = 0; i < count; i++) { - elements.push(new Element(this.electronApp, this.mainWindow, selector, i)) + elements.push(new Element(this.electronApp, this.workbench, selector, i)) } return elements } @@ -129,7 +130,7 @@ export class Page { async waitFor(predicate: () => Promise): Promise async waitFor(arg: number | string | (() => Promise)): Promise { if (typeof arg === 'number') { - await this.mainWindow.waitForTimeout(arg) + await this.workbench.waitForTimeout(arg) } else if (typeof arg === 'string') { await this.waitForSelector(arg) } else { @@ -149,7 +150,7 @@ export class Page { timeout, 300, ) - return new Element(this.electronApp, this.mainWindow, selector, 0) + return new Element(this.electronApp, this.workbench, selector, 0) } // ── Evaluate in iframe ────────────────────────────────────────────── diff --git a/packages/devtools/e2e/broken-project.spec.ts b/packages/devtools/e2e/broken-project.spec.ts index d70a8668..db6ba96d 100644 --- a/packages/devtools/e2e/broken-project.spec.ts +++ b/packages/devtools/e2e/broken-project.spec.ts @@ -2,7 +2,7 @@ import fs from 'fs' import os from 'os' import path from 'path' import { test, expect } from './fixtures' -import { ipcInvoke, closeProject, DEMO_APP_DIR } from './helpers' +import { ipcInvoke, DEMO_APP_DIR } from './helpers' import { ProjectChannel } from '../src/shared/ipc-channels' interface OpenProjectResult { @@ -123,7 +123,11 @@ test.describe('devkit dev server does not serve HTML for missing asset paths', ( `server returned HTML SPA fallback (status=${res.status}) for a JSON asset path; first 80 chars: ${JSON.stringify(body.slice(0, 80))}`, ).toBe(false) } finally { - await closeProject(mainWindow) + // These tests drive `ProjectChannel.Open` directly, so the session lives + // in the list window's own WorkbenchContext (no real workbench window is + // ever spawned) — close it via the same channel rather than + // `closeProject`, which only tears down real workbench BrowserWindows. + await ipcInvoke(mainWindow, ProjectChannel.Close).catch(() => {}) } }) @@ -143,7 +147,11 @@ test.describe('devkit dev server does not serve HTML for missing asset paths', ( const looksLikeHtml = /^\s* {}) } }) }) diff --git a/packages/devtools/e2e/console-filter-live.spec.ts b/packages/devtools/e2e/console-filter-live.spec.ts index e395c04a..78e52696 100644 --- a/packages/devtools/e2e/console-filter-live.spec.ts +++ b/packages/devtools/e2e/console-filter-live.spec.ts @@ -243,7 +243,10 @@ test.describe('Right-panel Console [service] de-noise filter (live)', () => { 10000, 100, ) - await openProjectInUI(mainWindow, FIXTURE_DIR, { waitMs: 20000 }) + // No workbench Page is kept: every project-scoped assertion below reaches + // the right-panel front-end / service host through electronApp.evaluate + // by webContents URL, never through a Playwright Page. + await openProjectInUI(electronApp, FIXTURE_DIR, { waitMs: 20000 }) await waitForSimulatorWebview(electronApp) await pollUntil( () => evalInSimulator(electronApp, `(() => !!document.querySelector('.device-shell-root'))()`).catch(() => false), @@ -279,7 +282,7 @@ test.describe('Right-panel Console [service] de-noise filter (live)', () => { }) test.afterAll(async () => { - await closeProject(mainWindow).catch(() => {}) + await closeProject(electronApp).catch(() => {}) await electronApp?.close().catch(() => {}) }) diff --git a/packages/devtools/e2e/console-filter-reset.spec.ts b/packages/devtools/e2e/console-filter-reset.spec.ts index 1a6826d3..4ba9dd57 100644 --- a/packages/devtools/e2e/console-filter-reset.spec.ts +++ b/packages/devtools/e2e/console-filter-reset.spec.ts @@ -52,9 +52,9 @@ test.describe('Console filter box is cleared on fresh project open', () => { useSharedProject(test, DEMO_APP_DIR) - test('stale console.textFilter is removed and the box is emptied', async ({ mainWindow, electronApp }) => { + test('stale console.textFilter is removed and the box is emptied', async ({ workbench, electronApp }) => { // Open the Console panel so the embedded Chrome DevTools front-end is mounted. - const consoleTab = mainWindow.getByRole('tab', { name: 'Console' }) + const consoleTab = workbench.getByRole('tab', { name: 'Console' }) await consoleTab.click() await expect(consoleTab).toHaveAttribute('data-active', 'true') diff --git a/packages/devtools/e2e/devtools-panel.spec.ts b/packages/devtools/e2e/devtools-panel.spec.ts index fc3deb78..bd2db596 100644 --- a/packages/devtools/e2e/devtools-panel.spec.ts +++ b/packages/devtools/e2e/devtools-panel.spec.ts @@ -9,14 +9,14 @@ test.describe('Simulator Panel', () => { useSharedProject(test, DEMO_APP_DIR) - test('toolbar has compile and simulator toggle buttons', async ({ mainWindow }) => { - expect(await findButtonByText(mainWindow, '普通编译')).toBe(true) - await expect(mainWindow.getByRole('group', { name: '面板可见性' })).toBeVisible() - await expect(mainWindow.getByTestId('layout-toolbar-toggle-simulator')).toBeVisible() + test('toolbar has compile and simulator toggle buttons', async ({ workbench }) => { + expect(await findButtonByText(workbench, '普通编译')).toBe(true) + await expect(workbench.getByRole('group', { name: '面板可见性' })).toBeVisible() + await expect(workbench.getByTestId('layout-toolbar-toggle-simulator')).toBeVisible() }) - test('toolbar has built-in right panel tabs', async ({ mainWindow }) => { - const tabLabels = await mainWindow.evaluate(() => { + test('toolbar has built-in right panel tabs', async ({ workbench }) => { + const tabLabels = await workbench.evaluate(() => { const buttons = document.querySelectorAll('button') const labels: string[] = [] buttons.forEach((btn) => { @@ -31,22 +31,22 @@ test.describe('Simulator Panel', () => { expect(tabLabels).toEqual(expect.arrayContaining(['WXML', 'AppData', 'Storage'])) }) - test('can toggle simulator panel visibility', async ({ mainWindow }) => { + test('can toggle simulator panel visibility', async ({ workbench }) => { // Under native-host (now the default runtime) the simulator is a // main-process WebContentsView, NOT a renderer `` — SimulatorPanel // deliberately skips the `` (Electron forbids nesting webviews, so // DeviceShell's per-page render-host webviews can only attach to a top-level - // WCV). So `mainWindow.locator('webview')` is 0 in BOTH states and can't + // WCV). So `workbench.locator('webview')` is 0 in BOTH states and can't // gate visibility. // - // The observable visibility signal in the main-window DOM is the + // The observable visibility signal in the workbench DOM is the // SimulatorPanel itself: its device-picker `` // carrying the device options, e.g. `iPhone SE`) mounts when the simulator // cell is in the compiled layout and unmounts when the cell is pruned. The // toolbar toggle flips `layoutStore.simulatorVisible`, which the layout // compile pass turns into the cell being present/absent (collapseInvisibleCells). - const deviceSelect = mainWindow.locator('select:has(option[value="iPhone SE"])') - const toggle = mainWindow.getByTestId('layout-toolbar-toggle-simulator') + const deviceSelect = workbench.locator('select:has(option[value="iPhone SE"])') + const toggle = workbench.getByTestId('layout-toolbar-toggle-simulator') await expect(deviceSelect).toHaveCount(1) @@ -57,8 +57,8 @@ test.describe('Simulator Panel', () => { await expect(deviceSelect).toHaveCount(1) }) - test('right panel tabs are rendered in the main window', async ({ mainWindow }) => { - const tabLabels = await mainWindow.evaluate(() => { + test('right panel tabs are rendered in the workbench window', async ({ workbench }) => { + const tabLabels = await workbench.evaluate(() => { const buttons = document.querySelectorAll('button') const labels: string[] = [] buttons.forEach((btn) => { diff --git a/packages/devtools/e2e/devtools-tab-order.spec.ts b/packages/devtools/e2e/devtools-tab-order.spec.ts index 050521ff..520763a5 100644 --- a/packages/devtools/e2e/devtools-tab-order.spec.ts +++ b/packages/devtools/e2e/devtools-tab-order.spec.ts @@ -38,9 +38,9 @@ test.describe('Right-panel DevTools tab bar (default order, Sources kept)', () = useSharedProject(test, DEMO_APP_DIR) - test('Elements / Console / Sources / Network all visible; Network click selects Network', async ({ mainWindow, electronApp }) => { + test('Elements / Console / Sources / Network all visible; Network click selects Network', async ({ workbench, electronApp }) => { // Open the Console panel so the embedded Chrome DevTools front-end is mounted. - const consoleTab = mainWindow.getByRole('tab', { name: 'Console' }) + const consoleTab = workbench.getByRole('tab', { name: 'Console' }) await consoleTab.click() await expect(consoleTab).toHaveAttribute('data-active', 'true') diff --git a/packages/devtools/e2e/dialog-zorder.spec.ts b/packages/devtools/e2e/dialog-zorder.spec.ts index 3a3f7531..dcd483fe 100644 --- a/packages/devtools/e2e/dialog-zorder.spec.ts +++ b/packages/devtools/e2e/dialog-zorder.spec.ts @@ -1,7 +1,7 @@ import { test, expect, _electron, type ElectronApplication, type Page } from '@playwright/test' import path from 'path' import { fileURLToPath } from 'url' -import { DEMO_APP_DIR, openProjectInUI, pollUntil, findMainWindow } from './helpers' +import { DEMO_APP_DIR, openProjectInUI, pollUntil } from './helpers' const __dirname = path.dirname(fileURLToPath(import.meta.url)) const TOOLBAR_FIXTURES = path.resolve(__dirname, 'fixtures', 'host-toolbar') @@ -24,7 +24,30 @@ test.describe('Dialog overlay z-order (real Electron): update dialog stays above test.describe.configure({ mode: 'serial' }) let electronApp: ElectronApplication - let mainWindow: Page + let workbench: Page + + /** + * `instance.context` (dialog-zorder-entry.js's `onSetup`) is the + * PROJECT-LIST window's own context — the simulator/host-toolbar this spec + * checks z-order against only exist on an OPEN project's own window (the + * list window never runs a mini-app), so every check below reaches the + * workbench window's context through `instance.projectWindows()`, not + * `instance.context`. + */ + interface DialogZorderInstance { + projectWindows(): Array<{ + context: { + views: { + hostToolbar: { loadFile(p: string): Promise } + getSimulatorWebContentsId(): number | null + getHostToolbarWebContentsId(): number | null + showUpdateDialog(info: { version: string; downloadUrl: string }): void + getUpdateDialogWebContentsId(): number | null + markOverlayReady(id: number): void + } + } + }> + } test.beforeAll(async () => { const entryPath = path.resolve(__dirname, 'dialog-zorder-entry.js') @@ -32,25 +55,19 @@ test.describe('Dialog overlay z-order (real Electron): update dialog stays above args: [entryPath], env: { ...process.env, NODE_ENV: 'test' }, }) - mainWindow = await findMainWindow(electronApp) - await mainWindow.waitForLoadState('domcontentloaded') - await openProjectInUI(mainWindow, DEMO_APP_DIR, { waitMs: 20_000 }) + workbench = await openProjectInUI(electronApp, DEMO_APP_DIR, { waitMs: 20_000 }) // Give the host-toolbar a live, sized strip so it actually attaches to // the window's contentView — the occlusion bug required an ACTUAL native // view mounted above the main window's renderer, not just a placeholder // waiting for content. await electronApp.evaluate((_electronMods, file) => { - const g = globalThis as unknown as { - __e2eDialogZorderInstance: { - context: { views: { hostToolbar: { loadFile(p: string): Promise } } } - } - } - return g.__e2eDialogZorderInstance.context.views.hostToolbar.loadFile(file) + const g = globalThis as unknown as { __e2eDialogZorderInstance: DialogZorderInstance } + return g.__e2eDialogZorderInstance.projectWindows()[0].context.views.hostToolbar.loadFile(file) }, path.join(TOOLBAR_FIXTURES, 'toolbar-64.html')) await pollUntil( - () => mainWindow.evaluate(() => { + () => workbench.evaluate(() => { const el = document.querySelector('[data-area="host-toolbar"]') return el ? Math.round(el.getBoundingClientRect().height) : -1 }), @@ -69,34 +86,20 @@ test.describe('Dialog overlay z-order (real Electron): update dialog stays above test('showUpdateDialog attaches its WCV above the live simulator and host-toolbar WCVs', async () => { const simulatorWcId = await electronApp.evaluate(() => { - const g = globalThis as unknown as { - __e2eDialogZorderInstance: { context: { views: { getSimulatorWebContentsId(): number | null } } } - } - return g.__e2eDialogZorderInstance.context.views.getSimulatorWebContentsId() + const g = globalThis as unknown as { __e2eDialogZorderInstance: DialogZorderInstance } + return g.__e2eDialogZorderInstance.projectWindows()[0].context.views.getSimulatorWebContentsId() }) expect(simulatorWcId, 'simulator WCV must be live before the z-order check is meaningful').not.toBeNull() const toolbarWcId = await electronApp.evaluate(() => { - const g = globalThis as unknown as { - __e2eDialogZorderInstance: { context: { views: { getHostToolbarWebContentsId(): number | null } } } - } - return g.__e2eDialogZorderInstance.context.views.getHostToolbarWebContentsId() + const g = globalThis as unknown as { __e2eDialogZorderInstance: DialogZorderInstance } + return g.__e2eDialogZorderInstance.projectWindows()[0].context.views.getHostToolbarWebContentsId() }) expect(toolbarWcId, 'host-toolbar WCV must be live before the z-order check is meaningful').not.toBeNull() const dialogWcId = await electronApp.evaluate(() => { - const g = globalThis as unknown as { - __e2eDialogZorderInstance: { - context: { - views: { - showUpdateDialog(info: { version: string; downloadUrl: string }): void - getUpdateDialogWebContentsId(): number | null - markOverlayReady(id: number): void - } - } - } - } - const views = g.__e2eDialogZorderInstance.context.views + const g = globalThis as unknown as { __e2eDialogZorderInstance: DialogZorderInstance } + const views = g.__e2eDialogZorderInstance.projectWindows()[0].context.views views.showUpdateDialog({ version: '2.0.0', downloadUrl: 'https://example.com/2.0.0.dmg' }) const id = views.getUpdateDialogWebContentsId() if (id !== null) views.markOverlayReady(id) diff --git a/packages/devtools/e2e/disk-sync.spec.ts b/packages/devtools/e2e/disk-sync.spec.ts index 7dcaa485..b0e3cc49 100644 --- a/packages/devtools/e2e/disk-sync.spec.ts +++ b/packages/devtools/e2e/disk-sync.spec.ts @@ -72,9 +72,9 @@ test.describe('fs-core disk↔editor sync (embedded workbench)', () => { // the first test pays the attach+ready wait. useSharedProject(test, DEMO_APP_DIR, { openOptions: { waitMs: 60_000 }, openTimeoutMs: 120_000 }) let workbenchReady = false - test.beforeEach(async ({ mainWindow, electronApp }) => { + test.beforeEach(async ({ workbench, electronApp }) => { if (workbenchReady) return - const status = await attachWorkbenchAndWaitReady(mainWindow, electronApp) + const status = await attachWorkbenchAndWaitReady(workbench, electronApp) expect(status, 'workbench must reach a ready status before driving the sync engine').toMatch( /workbench-ready|exthost-alive/, ) diff --git a/packages/devtools/e2e/dock-consolidation-smoke.spec.ts b/packages/devtools/e2e/dock-consolidation-smoke.spec.ts index 49426849..b8ef54b1 100644 --- a/packages/devtools/e2e/dock-consolidation-smoke.spec.ts +++ b/packages/devtools/e2e/dock-consolidation-smoke.spec.ts @@ -11,12 +11,12 @@ import { test, expect, _electron, type ElectronApplication, type Page as PwPage } from '@playwright/test' import path from 'path' import { fileURLToPath } from 'url' -import { openProjectInUI, closeProject, DEMO_APP_DIR, findMainWindow } from './helpers' +import { openProjectInUI, closeProject, DEMO_APP_DIR } from './helpers' const __dirname = path.dirname(fileURLToPath(import.meta.url)) let electronApp: ElectronApplication -let mainWindow: PwPage +let workbench: PwPage test.beforeAll(async () => { const appPath = path.resolve(__dirname, 'electron-entry.js') @@ -29,30 +29,28 @@ test.beforeAll(async () => { args: [appPath, `--user-data-dir=${userDataDir}`], env: { ...process.env, NODE_ENV: 'test' }, }) - mainWindow = await findMainWindow(electronApp) - await mainWindow.waitForLoadState('domcontentloaded') // Offscreen + blur so the smoke never steals focus. await electronApp.evaluate(async ({ BrowserWindow }) => { const win = BrowserWindow.getAllWindows()[0] if (win) { win.setPosition(-2000, -2000); win.blur() } }) - await openProjectInUI(mainWindow, DEMO_APP_DIR) + workbench = await openProjectInUI(electronApp, DEMO_APP_DIR) }) test.afterAll(async () => { - try { await closeProject(mainWindow) } catch { /* best effort */ } + try { await closeProject(electronApp) } catch { /* best effort */ } await electronApp.close() }) test('the sole dock layout renders (no flag, no FrameTree)', async () => { // DockView groups exist; the legacy FrameTree sim splitter does NOT. - await mainWindow.waitForSelector('[data-deck-group]', { timeout: 15000 }) - const groupCount = await mainWindow.locator('[data-deck-group]').count() + await workbench.waitForSelector('[data-deck-group]', { timeout: 15000 }) + const groupCount = await workbench.locator('[data-deck-group]').count() expect(groupCount, 'at least one dock group must render').toBeGreaterThanOrEqual(1) // No legacy FrameTree markers (the old path is deleted). - expect(await mainWindow.locator('[data-splitter="sim"]').count()).toBe(0) - expect(await mainWindow.locator('[data-area="native-simulator"]').count()).toBeGreaterThanOrEqual(0) + expect(await workbench.locator('[data-splitter="sim"]').count()).toBe(0) + expect(await workbench.locator('[data-area="native-simulator"]').count()).toBeGreaterThanOrEqual(0) }) test('the five debug tabs surface as deck tabs; simulator + editor are tabless structural panels', async () => { @@ -61,7 +59,7 @@ test('the five debug tabs surface as deck tabs; simulator + editor are tabless s // STRUCTURAL panels (`hideTab:true`): they own their own region and draw their // own chrome, so the dock renders NO tab for them — they are present as // tabless `[data-deck-panel-body]` bodies instead. - const tabIds = await mainWindow.evaluate(() => + const tabIds = await workbench.evaluate(() => Array.from(document.querySelectorAll('[data-deck-tab]')).map( (el) => el.getAttribute('data-deck-tab'), ), @@ -71,7 +69,7 @@ test('the five debug tabs surface as deck tabs; simulator + editor are tabless s } for (const id of ['simulator', 'editor']) { expect(tabIds, `structural panel '${id}' must NOT render a deck tab (hideTab)`).not.toContain(id) - const bodyCount = await mainWindow.locator(`[data-deck-panel-body="${id}"]`).count() + const bodyCount = await workbench.locator(`[data-deck-panel-body="${id}"]`).count() expect(bodyCount, `structural panel '${id}' must be mounted as a tabless dock body`).toBeGreaterThanOrEqual(1) } }) @@ -82,15 +80,15 @@ test('simulator chrome (device picker + page-path bar) renders in the dock — n // `[data-area="native-simulator"]` (the WCV anchor) AND the chrome around it. // The simulator panel is the active leaf in the default tree, so its body is // mounted. - await mainWindow.waitForSelector('[data-deck-panel-body="simulator"]', { timeout: 10000 }) - const region = mainWindow.locator('[data-area="native-simulator"]') + await workbench.waitForSelector('[data-deck-panel-body="simulator"]', { timeout: 10000 }) + const region = workbench.locator('[data-area="native-simulator"]') expect(await region.count(), 'the simulator WCV anchor region must render').toBeGreaterThanOrEqual(1) }) test('the simulator native WCV follows its slot (non-zero live bounds)', async () => { // The simulator slot must publish a non-zero rect to main, and the simulator // WebContentsView must be live (it loads simulator.html). - const slotRect = await mainWindow.evaluate(() => { + const slotRect = await workbench.evaluate(() => { const el = document.querySelector('[data-area="native-simulator"]') if (!el) return null const r = el.getBoundingClientRect() @@ -114,16 +112,16 @@ test('the simulator native WCV follows its slot (non-zero live bounds)', async ( test('switching a debug tab keeps the dock alive and mounts the new body (data refresh seam)', async () => { // Activate the WXML tab, then the Storage tab. Each activation mounts that // panel body (DockDebugTab fires the per-tab refresh on activation — M3). - const wxmlTab = mainWindow.locator('[data-deck-tab="wxml"]').first() + const wxmlTab = workbench.locator('[data-deck-tab="wxml"]').first() await wxmlTab.click() - await mainWindow.waitForSelector('[data-deck-panel-body="wxml"]', { timeout: 8000 }) + await workbench.waitForSelector('[data-deck-panel-body="wxml"]', { timeout: 8000 }) - const storageTab = mainWindow.locator('[data-deck-tab="storage"]').first() + const storageTab = workbench.locator('[data-deck-tab="storage"]').first() await storageTab.click() - await mainWindow.waitForSelector('[data-deck-panel-body="storage"]', { timeout: 8000 }) + await workbench.waitForSelector('[data-deck-panel-body="storage"]', { timeout: 8000 }) // The dock is still mounted after the switches. - expect(await mainWindow.locator('[data-deck-group]').count()).toBeGreaterThanOrEqual(1) + expect(await workbench.locator('[data-deck-group]').count()).toBeGreaterThanOrEqual(1) }) test('changing the device re-pins the simulator width live (device-width fidelity)', async () => { @@ -131,14 +129,14 @@ test('changing the device re-pins the simulator width live (device-width fidelit // width only at mount, so DockableLayout re-pins via setConstraint on a device // change. Switch the device inside the simulator body. - const changed = await mainWindow.evaluate(() => { + const changed = await workbench.evaluate(() => { const body = document.querySelector('[data-deck-panel-body="simulator"]') const select = body?.querySelector('select') as HTMLSelectElement | null if (!select || select.options.length < 2) return null @@ -153,8 +151,8 @@ test('changing the device re-pins the simulator width live (device-width fidelit // The region width must settle to a (different) positive value — the constraint // re-pin flowed through. We assert it stays positive and finite; an exact px // match depends on device metadata, so we assert it changed OR stayed valid. - await mainWindow.waitForTimeout(500) - const after = await mainWindow.evaluate(() => { + await workbench.waitForTimeout(500) + const after = await workbench.evaluate(() => { const el = document.querySelector('[data-area="native-simulator"]') return el ? el.getBoundingClientRect().width : 0 }) @@ -169,7 +167,7 @@ test('the drop seam rejects re-docking a structural panel — no-op, no tree mut // within-group tab reordering is covered by dock-tab-reorder.spec.ts.) // Snapshot editor's enclosing group id + the total group count BEFORE the drop. - const before = await mainWindow.evaluate(() => ({ + const before = await workbench.evaluate(() => ({ editorGroupId: document .querySelector('[data-deck-panel-body="editor"]') ?.closest('[data-deck-group]') @@ -178,7 +176,7 @@ test('the drop seam rejects re-docking a structural panel — no-op, no tree mut })) expect(before.editorGroupId, 'editor must start mounted inside a dock group').not.toBeNull() - const seamReached = await mainWindow.evaluate(() => { + const seamReached = await workbench.evaluate(() => { const groups = Array.from(document.querySelectorAll('[data-deck-group]')) as Array< HTMLElement & { __deckHandleDrop?: (panelId: string, zone: string) => void } > @@ -188,11 +186,11 @@ test('the drop seam rejects re-docking a structural panel — no-op, no tree mut return true }) expect(seamReached, 'the __deckHandleDrop seam must be reachable on a group').toBe(true) - await mainWindow.waitForSelector('[data-deck-group]', { timeout: 5000 }) + await workbench.waitForSelector('[data-deck-group]', { timeout: 5000 }) // The rejected drop changed NOTHING: editor sits in the same group and the // group count is unchanged (no split spawned a new group). - const after = await mainWindow.evaluate(() => ({ + const after = await workbench.evaluate(() => ({ editorGroupId: document .querySelector('[data-deck-panel-body="editor"]') ?.closest('[data-deck-group]') diff --git a/packages/devtools/e2e/dock-devtools-position-preset.spec.ts b/packages/devtools/e2e/dock-devtools-position-preset.spec.ts index 5010677a..1a93a3c5 100644 --- a/packages/devtools/e2e/dock-devtools-position-preset.spec.ts +++ b/packages/devtools/e2e/dock-devtools-position-preset.spec.ts @@ -42,12 +42,12 @@ import { } from '@playwright/test' import path from 'path' import { fileURLToPath } from 'url' -import { openProjectInUI, closeProject, DEMO_APP_DIR, findMainWindow } from './helpers' +import { openProjectInUI, closeProject, DEMO_APP_DIR } from './helpers' const __dirname = path.dirname(fileURLToPath(import.meta.url)) let electronApp: ElectronApplication -let mainWindow: PwPage +let workbench: PwPage test.beforeAll(async () => { const appPath = path.resolve(__dirname, 'electron-entry.js') @@ -61,18 +61,16 @@ test.beforeAll(async () => { args: [appPath, `--user-data-dir=${userDataDir}`], env: { ...process.env, NODE_ENV: 'test' }, }) - mainWindow = await findMainWindow(electronApp) - await mainWindow.waitForLoadState('domcontentloaded') await electronApp.evaluate(async ({ BrowserWindow }) => { const win = BrowserWindow.getAllWindows()[0] if (win) { win.setPosition(-2000, -2000); win.blur() } }) - await openProjectInUI(mainWindow, DEMO_APP_DIR) - await mainWindow.waitForSelector('[data-deck-group]', { timeout: 15000 }) + workbench = await openProjectInUI(electronApp, DEMO_APP_DIR) + await workbench.waitForSelector('[data-deck-group]', { timeout: 15000 }) }) test.afterAll(async () => { - try { await closeProject(mainWindow) } catch { /* best effort */ } + try { await closeProject(electronApp) } catch { /* best effort */ } await electronApp.close() }) @@ -97,10 +95,10 @@ async function editorWidth(page: PwPage): Promise { const MIN_HEALTHY_EDITOR_WIDTH = 50 test('[needs-real-electron] Bug #3: rightOfSimulator -> belowSimulator does not collapse the editor width', async () => { - await clickPreset(mainWindow, 'rightOfSimulator') - await clickPreset(mainWindow, 'belowSimulator') + await clickPreset(workbench, 'rightOfSimulator') + await clickPreset(workbench, 'belowSimulator') - const w = await editorWidth(mainWindow) + const w = await editorWidth(workbench) expect( w, `editor width after rightOfSimulator->belowSimulator must stay healthy (got ${w}px — a collapse to a few px is Bug #3)`, @@ -108,18 +106,18 @@ test('[needs-real-electron] Bug #3: rightOfSimulator -> belowSimulator does not }) test('[regress] inEditor -> belowSimulator keeps the editor visible (no root child-count change; must stay healthy)', async () => { - await clickPreset(mainWindow, 'inEditor') - await clickPreset(mainWindow, 'belowSimulator') + await clickPreset(workbench, 'inEditor') + await clickPreset(workbench, 'belowSimulator') - const w = await editorWidth(mainWindow) + const w = await editorWidth(workbench) expect(w).toBeGreaterThan(MIN_HEALTHY_EDITOR_WIDTH) }) test('[regress] inEditor <-> rightOfSimulator keeps the editor visible in both directions (neither pins a nested split)', async () => { - await clickPreset(mainWindow, 'inEditor') - await clickPreset(mainWindow, 'rightOfSimulator') - expect(await editorWidth(mainWindow)).toBeGreaterThan(MIN_HEALTHY_EDITOR_WIDTH) + await clickPreset(workbench, 'inEditor') + await clickPreset(workbench, 'rightOfSimulator') + expect(await editorWidth(workbench)).toBeGreaterThan(MIN_HEALTHY_EDITOR_WIDTH) - await clickPreset(mainWindow, 'inEditor') - expect(await editorWidth(mainWindow)).toBeGreaterThan(MIN_HEALTHY_EDITOR_WIDTH) + await clickPreset(workbench, 'inEditor') + expect(await editorWidth(workbench)).toBeGreaterThan(MIN_HEALTHY_EDITOR_WIDTH) }) diff --git a/packages/devtools/e2e/dock-keepalive-and-collapse.spec.ts b/packages/devtools/e2e/dock-keepalive-and-collapse.spec.ts index 3f1cb38b..73b01b84 100644 --- a/packages/devtools/e2e/dock-keepalive-and-collapse.spec.ts +++ b/packages/devtools/e2e/dock-keepalive-and-collapse.spec.ts @@ -32,12 +32,12 @@ import { } from '@playwright/test' import path from 'path' import { fileURLToPath } from 'url' -import { openProjectInUI, closeProject, DEMO_APP_DIR, findMainWindow } from './helpers' +import { openProjectInUI, closeProject, DEMO_APP_DIR } from './helpers' const __dirname = path.dirname(fileURLToPath(import.meta.url)) let electronApp: ElectronApplication -let mainWindow: PwPage +let workbench: PwPage test.beforeAll(async () => { const appPath = path.resolve(__dirname, 'electron-entry.js') @@ -51,19 +51,17 @@ test.beforeAll(async () => { args: [appPath, `--user-data-dir=${userDataDir}`], env: { ...process.env, NODE_ENV: 'test' }, }) - mainWindow = await findMainWindow(electronApp) - await mainWindow.waitForLoadState('domcontentloaded') // Offscreen + blur so the spec never steals focus. await electronApp.evaluate(async ({ BrowserWindow }) => { const win = BrowserWindow.getAllWindows()[0] if (win) { win.setPosition(-2000, -2000); win.blur() } }) - await openProjectInUI(mainWindow, DEMO_APP_DIR) - await mainWindow.waitForSelector('[data-deck-group]', { timeout: 15000 }) + workbench = await openProjectInUI(electronApp, DEMO_APP_DIR) + await workbench.waitForSelector('[data-deck-group]', { timeout: 15000 }) }) test.afterAll(async () => { - try { await closeProject(mainWindow) } catch { /* best effort */ } + try { await closeProject(electronApp) } catch { /* best effort */ } await electronApp.close() }) @@ -134,10 +132,10 @@ test('A3: a kept-alive debug body is NOT remounted across a tab switch (DOM iden // build a brand-new element and lose the imperatively-set attribute; (2) a // scrollTop on the first scrollable descendant. Both must survive A→B→A iff // the body was kept alive (display:none) rather than unmounted. - await activateTab(mainWindow, 'wxml') + await activateTab(workbench, 'wxml') const stamp = `keepalive-${Date.now()}` - const stamped = await mainWindow.evaluate((mark) => { + const stamped = await workbench.evaluate((mark) => { const body = document.querySelector('[data-deck-panel-body="wxml"]') as HTMLElement | null if (!body) return { ok: false, reason: 'no wxml body' } @@ -174,8 +172,8 @@ test('A3: a kept-alive debug body is NOT remounted across a tab switch (DOM iden // Switch AWAY to storage, then assert wxml stayed MOUNTED but hidden (the // keepalive contract: inactive body is display:none, NOT removed). - await activateTab(mainWindow, 'storage') - const wxmlHiddenButMounted = await mainWindow.evaluate(() => { + await activateTab(workbench, 'storage') + const wxmlHiddenButMounted = await workbench.evaluate(() => { const body = document.querySelector('[data-deck-panel-body="wxml"]') as HTMLElement | null if (!body) return { mounted: false, display: null as string | null } return { mounted: true, display: getComputedStyle(body).display } @@ -185,8 +183,8 @@ test('A3: a kept-alive debug body is NOT remounted across a tab switch (DOM iden // Switch BACK to wxml and assert the stamped element + scroll survived — i.e. // the body was the SAME instance, never remounted. - await activateTab(mainWindow, 'wxml') - const survived = await mainWindow.evaluate((mark) => { + await activateTab(workbench, 'wxml') + const survived = await workbench.evaluate((mark) => { const body = document.querySelector('[data-deck-panel-body="wxml"]') as HTMLElement | null if (!body) return { ok: false, markFound: false, scrollTop: null as number | null, hadScrollProbe: false } const marked = body.querySelector(`[data-e2e-keepalive-mark="${mark}"]`) @@ -225,10 +223,10 @@ test('B1: the simulator native WCV collapses (detaches, kept alive) when the sim // main maps the zero-area rect to COLLAPSE, `removeChildView`-ing the WCV from // the contentView tree (detach) while keeping its WebContents alive. So the // collapse is observed as `alive && bounds === null`. - const toggle = mainWindow.locator('[data-testid="layout-toolbar-toggle-simulator"]') + const toggle = workbench.locator('[data-testid="layout-toolbar-toggle-simulator"]') // Baseline: the simulator WCV is attached with live, non-zero bounds. - await mainWindow.waitForTimeout(500) + await workbench.waitForTimeout(500) const active = await simulatorView(electronApp) expect(active.alive, 'simulator WebContents must be alive while shown').toBe(true) expect(active.bounds, 'shown simulator WCV must be ATTACHED with live bounds').not.toBeNull() @@ -239,7 +237,7 @@ test('B1: the simulator native WCV collapses (detaches, kept alive) when the sim // Hide the simulator via the real toolbar toggle → detach-but-keep-alive. await toggle.click() - await mainWindow.waitForTimeout(800) + await workbench.waitForTimeout(800) const collapsed = await simulatorView(electronApp) expect( @@ -254,7 +252,7 @@ test('B1: the simulator native WCV collapses (detaches, kept alive) when the sim // Show the simulator again → the slot re-mounts → the anchor re-publishes a // non-zero rect → main re-attaches the WCV and restores its bounds. await toggle.click() - await mainWindow.waitForTimeout(800) + await workbench.waitForTimeout(800) const restored = await simulatorView(electronApp) expect(restored.alive, 'simulator WebContents must still be alive after being shown again').toBe(true) diff --git a/packages/devtools/e2e/dock-real-drag.spec.ts b/packages/devtools/e2e/dock-real-drag.spec.ts index 80a94a2c..df18be16 100644 --- a/packages/devtools/e2e/dock-real-drag.spec.ts +++ b/packages/devtools/e2e/dock-real-drag.spec.ts @@ -47,13 +47,12 @@ import { DEMO_APP_DIR, installConsoleCollector, readConsoleErrors, - findMainWindow, } from './helpers' const __dirname = path.dirname(fileURLToPath(import.meta.url)) let electronApp: ElectronApplication -let mainWindow: PwPage +let workbench: PwPage test.beforeAll(async () => { const appPath = path.resolve(__dirname, 'electron-entry.js') @@ -67,20 +66,18 @@ test.beforeAll(async () => { args: [appPath, `--user-data-dir=${userDataDir}`], env: { ...process.env, NODE_ENV: 'test' }, }) - mainWindow = await findMainWindow(electronApp) - await mainWindow.waitForLoadState('domcontentloaded') await installConsoleCollector(electronApp) // Offscreen + blur so the drag test never steals focus. await electronApp.evaluate(async ({ BrowserWindow }) => { const win = BrowserWindow.getAllWindows()[0] if (win) { win.setPosition(-2000, -2000); win.blur() } }) - await openProjectInUI(mainWindow, DEMO_APP_DIR) - await mainWindow.waitForSelector('[data-deck-group]', { timeout: 15000 }) + workbench = await openProjectInUI(electronApp, DEMO_APP_DIR) + await workbench.waitForSelector('[data-deck-group]', { timeout: 15000 }) }) test.afterAll(async () => { - try { await closeProject(mainWindow) } catch { /* best effort */ } + try { await closeProject(electronApp) } catch { /* best effort */ } await electronApp.close() }) @@ -282,14 +279,14 @@ test('CENTER over a locked editor group: indicator shows center, but a reorder-o // of wxml onto the editor group must be REJECTED (no churn), even though the // geometry-driven hover indicator still paints `center` (the gate is at drop // time, not hover time). Drives both PanelCapabilities gates with real geometry. - const before = await dockFingerprint(mainWindow) + const before = await dockFingerprint(workbench) const wxmlGroupBefore = before.groupOf['wxml'] const editorGroupBefore = before.groupOf['editor'] expect(wxmlGroupBefore, 'wxml must be docked before the drag').toBeTruthy() expect(editorGroupBefore, 'editor must be docked before the drag').toBeTruthy() expect(wxmlGroupBefore, 'wxml and editor start in DIFFERENT groups').not.toBe(editorGroupBefore) - const r = await realDragTab(mainWindow, 'wxml', 'editor', 0.5, 0.5) + const r = await realDragTab(workbench, 'wxml', 'editor', 0.5, 0.5) expect(r.error, `drag must not throw: ${r.error}`).toBeNull() expect(r.ok, 'drag sequence must run').toBe(true) // The live indicator paints `center` while hovering the interior (presentation @@ -297,8 +294,8 @@ test('CENTER over a locked editor group: indicator shows center, but a reorder-o expect(r.indicatorSeen, 'a drop-zone indicator must appear during dragover').toBe(true) expect(r.zoneAtHover, 'interior hover must compute the center zone').toBe('center') - await mainWindow.waitForTimeout(300) - const after = await dockFingerprint(mainWindow) + await workbench.waitForTimeout(300) + const after = await dockFingerprint(workbench) // The drop is rejected on BOTH gates: wxml stays in its own group, never joins // editor, and the overall group membership is unchanged. @@ -313,20 +310,20 @@ test('LEFT band over a locked editor group: indicator shows left, but a reorder- // draggable:false). So an edge (far-left band) drop of console onto the editor // group is REJECTED: no new split, console stays put. The hover indicator still // paints `left` (geometry-only presentation). - const before = await dockFingerprint(mainWindow) + const before = await dockFingerprint(workbench) const consoleGroupBefore = before.groupOf['console'] expect(consoleGroupBefore, 'console must be docked before the drag').toBeTruthy() const splitsBefore = before.splits.length // Drop console onto the far-left 5% band of the group that owns editor. - const r = await realDragTab(mainWindow, 'console', 'editor', 0.05, 0.5) + const r = await realDragTab(workbench, 'console', 'editor', 0.05, 0.5) expect(r.error, `drag must not throw: ${r.error}`).toBeNull() // The indicator paints `left` over the left band (presentation is geometry-only). expect(r.indicatorSeen, 'a drop-zone indicator must appear during dragover').toBe(true) expect(r.zoneAtHover, 'far-left hover must compute the left zone').toBe('left') - await mainWindow.waitForTimeout(300) - const after = await dockFingerprint(mainWindow) + await workbench.waitForTimeout(300) + const after = await dockFingerprint(workbench) // The edge drop is rejected: no split is introduced and console stays in its // original group (it never tears out toward the editor region). @@ -337,7 +334,7 @@ test('LEFT band over a locked editor group: indicator shows left, but a reorder- }) test('self-drop center of a reorder-only debug tab stays WITHIN its own group (never leaves, never crashes)', async () => { - const before = await dockFingerprint(mainWindow) + const before = await dockFingerprint(workbench) // Pick a real DRAGGABLE source: a debug tab (the source must own a // `[data-deck-tab]`). The structural simulator/editor panels are tabless // (hideTab) and draggable:false, so they can never be a drag source. @@ -349,11 +346,11 @@ test('self-drop center of a reorder-only debug tab stays WITHIN its own group (n // A center drop onto its OWN group is the one motion `reorder-only` permits — it // REORDERS within the group (it never leaves). The exact resulting index is // pointer-derived; the invariant is: same group, same membership set, no crash. - const r = await realDragTab(mainWindow, selfPanel, selfPanel, 0.5, 0.5) + const r = await realDragTab(workbench, selfPanel, selfPanel, 0.5, 0.5) expect(r.error, `self-drop must not throw: ${r.error}`).toBeNull() - await mainWindow.waitForTimeout(200) - const after = await dockFingerprint(mainWindow) + await workbench.waitForTimeout(200) + const after = await dockFingerprint(workbench) // The panel stays in its own group (reorder-only never tears out). expect(after.groupOf[selfPanel], 'self-center drop keeps the panel in its own group').toBe(selfGroupBefore) // Group membership (as a SET) is unchanged — only the within-group ORDER may shift. @@ -373,19 +370,19 @@ test('native anchor: the simulator (draggable:false) cannot be torn out — a dr // its live WCV bounds are unchanged. const beforeBounds = await simulatorBounds(electronApp) expect(beforeBounds, 'simulator WCV must have live bounds').not.toBeNull() - const before = await dockFingerprint(mainWindow) + const before = await dockFingerprint(workbench) const simGroupBefore = before.groupOf['simulator'] expect(simGroupBefore, 'simulator must be docked').toBeTruthy() // The simulator has no `[data-deck-tab]`, so realDragTab cannot pick it up — the // gesture fails to even start, which IS the contract (a draggable:false panel // can never be lifted). - const r = await realDragTab(mainWindow, 'simulator', 'editor', 0.5, 0.95) + const r = await realDragTab(workbench, 'simulator', 'editor', 0.5, 0.95) expect(r.ok, 'a draggable:false panel cannot start a drag (no source tab)').toBe(false) expect(r.error, 'the absent tab is the reason the drag never starts').toMatch(/no source tab simulator/) - await mainWindow.waitForTimeout(300) - const after = await dockFingerprint(mainWindow) + await workbench.waitForTimeout(300) + const after = await dockFingerprint(workbench) // The simulator stays in its group and the tree is unchanged. expect(after.groupOf['simulator'], 'simulator never leaves its group').toBe(simGroupBefore) expect(after.groupOf, 'a failed simulator drag must not churn the tree').toEqual(before.groupOf) diff --git a/packages/devtools/e2e/dock-resize-sync-regressions.spec.ts b/packages/devtools/e2e/dock-resize-sync-regressions.spec.ts index e5fa932d..94eca698 100644 --- a/packages/devtools/e2e/dock-resize-sync-regressions.spec.ts +++ b/packages/devtools/e2e/dock-resize-sync-regressions.spec.ts @@ -52,12 +52,12 @@ import { } from '@playwright/test' import path from 'path' import { fileURLToPath } from 'url' -import { openProjectInUI, closeProject, DEMO_APP_DIR, findMainWindow } from './helpers' +import { openProjectInUI, closeProject, DEMO_APP_DIR } from './helpers' const __dirname = path.dirname(fileURLToPath(import.meta.url)) let electronApp: ElectronApplication -let mainWindow: PwPage +let workbench: PwPage test.beforeAll(async () => { const appPath = path.resolve(__dirname, 'electron-entry.js') @@ -71,19 +71,17 @@ test.beforeAll(async () => { args: [appPath, `--user-data-dir=${userDataDir}`], env: { ...process.env, NODE_ENV: 'test' }, }) - mainWindow = await findMainWindow(electronApp) - await mainWindow.waitForLoadState('domcontentloaded') // Offscreen + blur so the spec never steals focus (real pointer drags below). await electronApp.evaluate(async ({ BrowserWindow }) => { const win = BrowserWindow.getAllWindows()[0] if (win) { win.setPosition(-2000, -2000); win.blur() } }) - await openProjectInUI(mainWindow, DEMO_APP_DIR) - await mainWindow.waitForSelector('[data-deck-group]', { timeout: 15000 }) + workbench = await openProjectInUI(electronApp, DEMO_APP_DIR) + await workbench.waitForSelector('[data-deck-group]', { timeout: 15000 }) }) test.afterAll(async () => { - try { await closeProject(mainWindow) } catch { /* best effort */ } + try { await closeProject(electronApp) } catch { /* best effort */ } await electronApp.close() }) @@ -185,10 +183,10 @@ function topShare(sizes: number[]): number { test('[needs-real-electron] R1: a sub-0.5% real splitter drag PERSISTS to the model (v3 normalizes it above FLEX_RATIO_TOLERANCE)', async () => { // col-main is the column split [editor | debug], both flexible. Reset to a // clean 50/50 so the small drag's flexible delta is unambiguous. - await applyLayout(mainWindow, 'col-main', [50, 50]) - await mainWindow.waitForTimeout(300) + await applyLayout(workbench, 'col-main', [50, 50]) + await workbench.waitForTimeout(300) - const before = await splitInfo(mainWindow, 'col-main') + const before = await splitInfo(workbench, 'col-main') expect(before, 'col-main must render with a resize handle').not.toBeNull() expect(before!.handle, 'col-main must expose a [data-deck-resize-handle]').not.toBeNull() expect(before!.axis).toBe('h') @@ -202,10 +200,10 @@ test('[needs-real-electron] R1: a sub-0.5% real splitter drag PERSISTS to the mo const dy = 3 // observed: container ~908px → 0.5% ≈ 4.5px, so 3px is sub-0.5% expect(dy, `the drag (${dy}px) must be under 0.5% of the container (${halfPctPx.toFixed(2)}px) to exercise the epsilon`).toBeLessThan(halfPctPx) - await dragHandle(mainWindow, 'col-main', [{ dx: 0, dy }]) - await mainWindow.waitForTimeout(400) + await dragHandle(workbench, 'col-main', [{ dx: 0, dy }]) + await workbench.waitForTimeout(400) - const after = await splitInfo(mainWindow, 'col-main') + const after = await splitInfo(workbench, 'col-main') // SANITY: the real pointer drag DID move the VISIBLE split — rrp committed the // small move (so this is a genuine completed user drag, not a no-op gesture). @@ -255,7 +253,7 @@ test('[needs-real-electron] R2: a fixed-px split does NOT corrupt the flexible c // is also 100 ⇒ 100==100 within `FLEX_RATIO_TOLERANCE` ⇒ the echo (and any // ratio-preserving spontaneous re-measure) is SKIPPED ⇒ the raw seed weight 1 // is never overwritten. - const root = await mainWindow.evaluate(() => { + const root = await workbench.evaluate(() => { const s = document.querySelector('[data-deck-split="dock-root"]') if (!s) return null return { @@ -311,18 +309,18 @@ test('[needs-real-electron] R3: a drag that returns to origin does NOT freeze la // (≈)its origin (net-zero change), but with no gate flag there is nothing left // armed — so a later programmatic `setSizes` syncs the visible split // immediately. - await applyLayout(mainWindow, 'col-main', [50, 50]) - await mainWindow.waitForTimeout(300) + await applyLayout(workbench, 'col-main', [50, 50]) + await workbench.waitForTimeout(300) - const before = await splitInfo(mainWindow, 'col-main') + const before = await splitInfo(workbench, 'col-main') expect(before!.handle, 'col-main must expose a handle').not.toBeNull() const beforeTop = before!.sizes[0]! // Real pointer drag AWAY (+80px) then BACK to the exact origin, then release. - await dragHandle(mainWindow, 'col-main', [{ dx: 0, dy: 80 }, { dx: 0, dy: 0 }]) - await mainWindow.waitForTimeout(400) + await dragHandle(workbench, 'col-main', [{ dx: 0, dy: 80 }, { dx: 0, dy: 0 }]) + await workbench.waitForTimeout(400) - const afterDrag = await splitInfo(mainWindow, 'col-main') + const afterDrag = await splitInfo(workbench, 'col-main') // The drag returned to origin: the visible split is back where it started. expect( Math.abs(afterDrag!.sizes[0]! - beforeTop), @@ -330,10 +328,10 @@ test('[needs-real-electron] R3: a drag that returns to origin does NOT freeze la ).toBeLessThanOrEqual(4) // Now drive a PROGRAMMATIC setSizes to a dramatically different ratio. - await applyLayout(mainWindow, 'col-main', [10, 90]) - await mainWindow.waitForTimeout(500) + await applyLayout(workbench, 'col-main', [10, 90]) + await workbench.waitForTimeout(500) - const afterProg = await splitInfo(mainWindow, 'col-main') + const afterProg = await splitInfo(workbench, 'col-main') // The model + mirror update regardless (setSizes mutates the model). expect(afterProg!.sizesAttr, 'the programmatic setSizes lands in the model').toBe('10,90') diff --git a/packages/devtools/e2e/dock-resize-sync-regressions2.spec.ts b/packages/devtools/e2e/dock-resize-sync-regressions2.spec.ts index e983f121..2fa34ab4 100644 --- a/packages/devtools/e2e/dock-resize-sync-regressions2.spec.ts +++ b/packages/devtools/e2e/dock-resize-sync-regressions2.spec.ts @@ -42,12 +42,12 @@ import { import path from 'path' import fs from 'fs' import { fileURLToPath } from 'url' -import { openProjectInUI, closeProject, DEMO_APP_DIR, findMainWindow } from './helpers' +import { openProjectInUI, closeProject, DEMO_APP_DIR } from './helpers' const __dirname = path.dirname(fileURLToPath(import.meta.url)) let electronApp: ElectronApplication -let mainWindow: PwPage +let workbench: PwPage const SPEC_USERDATA = 'dock-resize-sync-regressions2' @@ -66,19 +66,17 @@ test.beforeAll(async () => { args: [appPath, `--user-data-dir=${userDataDir}`], env: { ...process.env, NODE_ENV: 'test' }, }) - mainWindow = await findMainWindow(electronApp) - await mainWindow.waitForLoadState('domcontentloaded') // Offscreen + blur so the spec never steals focus. await electronApp.evaluate(async ({ BrowserWindow }) => { const win = BrowserWindow.getAllWindows()[0] if (win) { win.setPosition(-2000, -2000); win.blur() } }) - await openProjectInUI(mainWindow, DEMO_APP_DIR) - await mainWindow.waitForSelector('[data-deck-group]', { timeout: 15000 }) + workbench = await openProjectInUI(electronApp, DEMO_APP_DIR) + await workbench.waitForSelector('[data-deck-group]', { timeout: 15000 }) }) test.afterAll(async () => { - try { await closeProject(mainWindow) } catch { /* best effort */ } + try { await closeProject(electronApp) } catch { /* best effort */ } await electronApp.close() }) @@ -140,11 +138,11 @@ test('[needs-real-electron] R-KB: a keyboard (Arrow-key) resize persists to the // Reset to a clean, symmetric [50,50] via the programmatic seam so the start // state is deterministic and the keyboard move is unambiguous. - const reset = await applyLayout(mainWindow, split, [50, 50]) + const reset = await applyLayout(workbench, split, [50, 50]) expect(reset, 'the col-main write-back seam must be reachable to seed [50,50]').toBe(true) - await mainWindow.waitForTimeout(400) + await workbench.waitForTimeout(400) - const before = await panelSizes(mainWindow, split) + const before = await panelSizes(workbench, split) expect(before, `the ${split} split must render with two flexible panels`).not.toBeNull() expect(before!.axis).toBe('h') expect(before!.sizes.length, 'col-main must have exactly two stacked panels').toBe(2) @@ -158,7 +156,7 @@ test('[needs-real-electron] R-KB: a keyboard (Arrow-key) resize persists to the // SEPARATOR (rrp's listener reads `e.currentTarget`). For a vertical split // ArrowUp shrinks the top panel by 5% per press. We press several times so // the move is well above measurement noise. - const kb = await mainWindow.evaluate((id) => { + const kb = await workbench.evaluate((id) => { const splitEl = document.querySelector(`[data-deck-split="${id}"]`) if (!splitEl) return { ok: false, why: 'no split element', role: null, tabIndex: null } const handle = splitEl.querySelector('[data-deck-resize-handle]') as HTMLElement | null @@ -184,9 +182,9 @@ test('[needs-real-electron] R-KB: a keyboard (Arrow-key) resize persists to the expect(kb.role, 'rrp resize handle must be role="separator" (keyboard-operable)').toBe('separator') expect(kb.tabIndex, 'rrp resize handle must be focusable (tabIndex 0)').toBe(0) - await mainWindow.waitForTimeout(500) + await workbench.waitForTimeout(500) - const after = await panelSizes(mainWindow, split) + const after = await panelSizes(workbench, split) expect(after, 'col-main must still render after the keyboard resize').not.toBeNull() expect(after!.sizes.length).toBe(2) @@ -260,14 +258,14 @@ test.fixme( // whose flexible ratios are unchanged, so v3 SKIPS its write-back. const split = 'dock-root' - const rootBefore = await panelSizes(mainWindow, split) + const rootBefore = await panelSizes(workbench, split) expect(rootBefore, 'the root split must render').not.toBeNull() expect(rootBefore!.sizes.length, 'root split has two children (sim | main)').toBe(2) const weightsBefore = rootBefore!.sizesAttr expect(weightsBefore, 'root split must mirror its raw weights').toBeTruthy() // Locate the root split's DIRECT resize handle and its midpoint. - const handleBox = await mainWindow.evaluate(() => { + const handleBox = await workbench.evaluate(() => { const splitEl = document.querySelector('[data-deck-split="dock-root"]') if (!splitEl) return null const handle = splitEl.querySelector('[data-deck-resize-handle]') as HTMLElement | null @@ -283,19 +281,19 @@ test.fixme( // exactly what rrp listens to (pointerdown/move/up + setPointerCapture). const cx = handleBox!.x const cy = handleBox!.y - await mainWindow.mouse.move(cx, cy) - await mainWindow.mouse.down() - for (const dx of [15, 30, 45, 60]) await mainWindow.mouse.move(cx + dx, cy, { steps: 4 }) - await mainWindow.waitForTimeout(50) - for (const dx of [45, 30, 15, 0]) await mainWindow.mouse.move(cx + dx, cy, { steps: 4 }) - await mainWindow.mouse.up() - await mainWindow.waitForTimeout(300) + await workbench.mouse.move(cx, cy) + await workbench.mouse.down() + for (const dx of [15, 30, 45, 60]) await workbench.mouse.move(cx + dx, cy, { steps: 4 }) + await workbench.waitForTimeout(50) + for (const dx of [45, 30, 15, 0]) await workbench.mouse.move(cx + dx, cy, { steps: 4 }) + await workbench.mouse.up() + await workbench.waitForTimeout(300) - const weightsAfterDrag = (await panelSizes(mainWindow, split))!.sizesAttr + const weightsAfterDrag = (await panelSizes(workbench, split))!.sizesAttr // Trigger a SPONTANEOUS re-measure WITHOUT any user resize: change the device // so the fixed-px sim leaf re-pins → the root re-measures → `onLayoutChanged`. - const changed = await mainWindow.evaluate(() => { + const changed = await workbench.evaluate(() => { const body = document.querySelector('[data-deck-panel-body="simulator"]') const select = body?.querySelector('select') as HTMLSelectElement | null if (!select || select.options.length < 2) return null @@ -306,9 +304,9 @@ test.fixme( return { from: cur, to: next, label: select.options[next]!.textContent } }) expect(changed, 'the device