Dependency audit / audit (push) Canceled after 0s
CI / plan (push) Canceled after 0s
Gitoxide helper admission / macos-latest (push) Canceled after 0s
Gitoxide helper admission / ubuntu-latest (push) Canceled after 0s
Gitoxide helper admission / windows-latest (push) Canceled after 0s
Runtime Host peer admission / quality (push) Canceled after 0s
Windows recovery / windows_recovery (push) Canceled after 0s
CI / heavy (push) Canceled after 0s
CI / test (push) Canceled after 0s
678 lines
26 KiB
TypeScript
678 lines
26 KiB
TypeScript
/*
|
|
* Licensed to the Apache Software Foundation (ASF) under one
|
|
* or more contributor license agreements. See the NOTICE file
|
|
* distributed with this work for additional information
|
|
* regarding copyright ownership. The ASF licenses this file
|
|
* to you under the Apache License, Version 2.0 (the
|
|
* "License"); you may not use this file except in compliance
|
|
* with the License. You may obtain a copy of the License at
|
|
*
|
|
* http://www.apache.org/licenses/LICENSE-2.0
|
|
*
|
|
* Unless required by applicable law or agreed to in writing,
|
|
* software distributed under the License is distributed on an
|
|
* "AS IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY
|
|
* KIND, either express or implied. See the License for the
|
|
* specific language governing permissions and limitations
|
|
* under the License.
|
|
*/
|
|
|
|
import { _electron as electron, test as base, expect } from '@playwright/test';
|
|
import type { ElectronApplication, Page } from '@playwright/test';
|
|
import { execFile } from 'node:child_process';
|
|
import { mkdir, mkdtemp, rm, writeFile } from 'node:fs/promises';
|
|
import { tmpdir } from 'node:os';
|
|
import path from 'node:path';
|
|
import { promisify } from 'node:util';
|
|
import { createProjectCatalog } from '@maka/storage/project-catalog';
|
|
import { createSessionStore } from '@maka/storage/session-store';
|
|
import { createSettingsStore } from '@maka/storage/settings-store';
|
|
import {
|
|
resolveStorageRoot,
|
|
tryAcquireInteractiveRootOwner,
|
|
} from '@maka/storage/root-authority';
|
|
import { openInteractiveRuntimePolicyStoresForWrite } from '@maka/storage/runtime-policy-stores';
|
|
import { buildFixtureEnv, isCiLinuxDisplay } from '../../../scripts/fixture-env.mjs';
|
|
import { closeElectronApplication } from '../../../scripts/electron-lifecycle.mjs';
|
|
|
|
const DESKTOP_ROOT = process.cwd();
|
|
const execFileAsync = promisify(execFile);
|
|
|
|
/**
|
|
* The composer's text surface. It is Astryx's `ChatComposerInput`, so the
|
|
* typing target is the contentEditable inside the component root, not the
|
|
* root itself — and `toHaveValue` / `.value` no longer apply: assert with
|
|
* `toHaveText` and type with `fill` / `pressSequentially`.
|
|
*
|
|
* `toHaveText` reads an inline token's rendered label, not the value it
|
|
* serializes to on send. Assert a mention's wire form through the fake
|
|
* backend echo and its display form on the sent message.
|
|
*/
|
|
export const COMPOSER_INPUT = '.maka-composer-editor [contenteditable="true"]';
|
|
export const PARENT_REMOVAL_PARENT_NAME = '待删除的父任务';
|
|
export const PARENT_REMOVAL_CHILD_NAME = '应归档的子任务';
|
|
/** Directory basename, and so the Project name the workspace picker lists. */
|
|
export const NEW_TASK_PROJECT_NAME = 'new-task-project';
|
|
|
|
/**
|
|
* Restore the navigation column through the titlebar action when a test needs
|
|
* controls that only exist in the expanded sidebar. The fixture starts with
|
|
* the sidebar collapsed, and the action follows the configured UI locale.
|
|
*/
|
|
export async function ensureSidebarExpanded(page: Page): Promise<void> {
|
|
const expandSidebar = page.getByRole('button', {
|
|
name: /^(?:展开侧边栏|Expand sidebar)$/,
|
|
});
|
|
if (!(await expandSidebar.isVisible())) return;
|
|
|
|
await expandSidebar.click();
|
|
await expect(
|
|
page.getByRole('button', { name: /^(?:收起侧边栏|Collapse sidebar)$/ }),
|
|
).toBeVisible();
|
|
}
|
|
|
|
/**
|
|
* Wait for the default Host's Coordination Session and the WorkHub projection
|
|
* to agree that the surface is ready. A mounted WorkHub main is not sufficient:
|
|
* it is also rendered while the Host reconnects and the projection reloads.
|
|
*/
|
|
export async function waitForWorkHubReady(page: Page, workCount: number): Promise<void> {
|
|
await expect
|
|
.poll(async () => {
|
|
const snapshot = await page.evaluate(() => window.maka.runtimeHostProfiles.getSnapshot());
|
|
return snapshot.entries.find(({ isDefault }) => isDefault)?.readiness;
|
|
})
|
|
.toBe('ready');
|
|
await expect(page.getByText(`${workCount} 项工作`, { exact: true })).toBeVisible();
|
|
}
|
|
|
|
/**
|
|
* Wait for Runtime's authoritative Skill projection, not merely for the
|
|
* composer DOM to mount. The renderer requests this projection after its first
|
|
* render, so a visible editor can still have an empty `/` source during cold
|
|
* start.
|
|
*/
|
|
export async function waitForInvocableSkills(
|
|
page: Page,
|
|
expectedIds: readonly string[],
|
|
): Promise<void> {
|
|
await expect
|
|
.poll(async () =>
|
|
page.evaluate(async () =>
|
|
(await window.maka.skills.listInvocable(undefined)).map((skill) => skill.id),
|
|
),
|
|
)
|
|
.toEqual(expect.arrayContaining(expectedIds));
|
|
}
|
|
|
|
/**
|
|
* Pre-seed a real-looking connection into the throwaway workspace so onboarding
|
|
* clears and the composer is enabled. Actual sessions still run on the fake
|
|
* backend (BackendRegistry override in main); this only satisfies the UI
|
|
* readiness gates. Kept in the fixture so test data stays out of production main.
|
|
*/
|
|
async function seedE2eConnection(userDataDir: string): Promise<void> {
|
|
const workspaceRoot = path.join(userDataDir, 'workspaces', 'default');
|
|
const capability = await resolveStorageRoot({ path: workspaceRoot, kind: 'interactive' });
|
|
const owner = await tryAcquireInteractiveRootOwner(capability);
|
|
if (!owner) throw new Error('E2E fixture could not acquire its isolated Runtime Host root');
|
|
try {
|
|
const stores = await openInteractiveRuntimePolicyStoresForWrite(owner.lease);
|
|
const catalog = await stores.connectionCatalog.getSnapshot();
|
|
const created = await stores.connectionCatalog.create({
|
|
expectedCatalogRevision: catalog.revision,
|
|
connection: {
|
|
slug: 'e2e',
|
|
name: 'E2E',
|
|
providerType: 'anthropic',
|
|
enabled: true,
|
|
enabledModelIds: ['claude-sonnet-4-5-20250929'],
|
|
},
|
|
});
|
|
if (created.kind !== 'committed') {
|
|
throw new Error(`E2E connection seed was not committed: ${created.kind}`);
|
|
}
|
|
const connection = created.snapshot.connections.find(({ slug }) => slug === 'e2e');
|
|
if (!connection) throw new Error('E2E connection seed is missing from the committed catalog');
|
|
const credential = await stores.credentialVault.set({
|
|
locator: {
|
|
scope: 'connection',
|
|
connectionId: connection.connectionId,
|
|
kind: 'api_key',
|
|
},
|
|
expected: null,
|
|
secret: 'e2e-placeholder',
|
|
});
|
|
if (credential.kind !== 'committed') {
|
|
throw new Error(`E2E credential seed was not committed: ${credential.kind}`);
|
|
}
|
|
const modelFetch = await stores.operations.beginModelFetch(connection.connectionId);
|
|
if (modelFetch.kind !== 'ready') {
|
|
throw new Error(`E2E model inventory seed could not start: ${modelFetch.kind}`);
|
|
}
|
|
const modelInventory = await stores.operations.completeModelFetch(modelFetch.ticket, {
|
|
models: [{ id: 'claude-sonnet-4-5-20250929' }],
|
|
source: 'fallback',
|
|
fetchedAt: 0,
|
|
});
|
|
if (modelInventory.kind !== 'committed') {
|
|
throw new Error(`E2E model inventory seed was not committed: ${modelInventory.kind}`);
|
|
}
|
|
const defaultTarget = await stores.connectionCatalog.setDefaultTarget({
|
|
expectedCatalogRevision: modelInventory.snapshot.revision,
|
|
target: {
|
|
connectionId: connection.connectionId,
|
|
modelId: 'claude-sonnet-4-5-20250929',
|
|
},
|
|
});
|
|
if (defaultTarget.kind !== 'committed') {
|
|
throw new Error(`E2E default target seed was not committed: ${defaultTarget.kind}`);
|
|
}
|
|
} finally {
|
|
await owner.close();
|
|
}
|
|
}
|
|
|
|
async function seedE2eLocale(userDataDir: string, locale: 'zh' | 'en'): Promise<void> {
|
|
const workspaceRoot = path.join(userDataDir, 'workspaces', 'default');
|
|
await createSettingsStore(workspaceRoot).update({
|
|
personalization: { uiLocale: locale },
|
|
});
|
|
}
|
|
|
|
/** Rows for the rail-render contract: enough that a stray render is loud. */
|
|
export const RAIL_RENDER_SESSION_COUNT = 12;
|
|
|
|
async function seedRailRenderSessions(userDataDir: string): Promise<void> {
|
|
const workspaceRoot = path.join(userDataDir, 'workspaces', 'default');
|
|
const store = createSessionStore(workspaceRoot);
|
|
try {
|
|
for (let index = 0; index < RAIL_RENDER_SESSION_COUNT; index += 1) {
|
|
await store.create({
|
|
cwd: path.join(userDataDir, 'project'),
|
|
llmConnectionSlug: 'e2e',
|
|
model: 'claude-sonnet-4-5-20250929',
|
|
permissionMode: 'ask',
|
|
name: `Rail row ${index}`,
|
|
labels: [],
|
|
});
|
|
}
|
|
} finally {
|
|
await store.close?.();
|
|
}
|
|
}
|
|
|
|
async function seedParentRemovalSessions(userDataDir: string): Promise<void> {
|
|
const workspaceRoot = path.join(userDataDir, 'workspaces', 'default');
|
|
const store = createSessionStore(workspaceRoot);
|
|
try {
|
|
const parent = await store.create({
|
|
cwd: path.join(userDataDir, 'project'),
|
|
llmConnectionSlug: 'e2e',
|
|
model: 'claude-sonnet-4-5-20250929',
|
|
permissionMode: 'ask',
|
|
name: PARENT_REMOVAL_PARENT_NAME,
|
|
labels: [],
|
|
});
|
|
await store.createSubagent({
|
|
cwd: path.join(userDataDir, 'project'),
|
|
llmConnectionSlug: 'e2e',
|
|
model: 'claude-sonnet-4-5-20250929',
|
|
permissionMode: 'ask',
|
|
name: PARENT_REMOVAL_CHILD_NAME,
|
|
labels: [],
|
|
subagentParent: {
|
|
kind: 'subagent',
|
|
parentSessionId: parent.id,
|
|
spawnedBy: {
|
|
parentRunId: 'e2e-parent-run',
|
|
parentTurnId: 'e2e-parent-turn',
|
|
toolCallId: 'e2e-spawn-call',
|
|
},
|
|
lifecycle: 'foreground',
|
|
},
|
|
subagentRuntime: {
|
|
schemaVersion: 1,
|
|
definitionVersion: 1,
|
|
agentId: 'implementation',
|
|
agentName: 'Implementation',
|
|
profile: 'implementation',
|
|
systemPrompt: 'Implement the assigned task.',
|
|
toolNames: ['Read', 'Write'],
|
|
categoryPolicy: {},
|
|
},
|
|
subagentSpawn: {
|
|
schemaVersion: 1,
|
|
requestFingerprint: 'a'.repeat(64),
|
|
initialTurnId: 'e2e-child-turn',
|
|
initialRunId: 'e2e-child-run',
|
|
},
|
|
});
|
|
} finally {
|
|
await store.close?.();
|
|
}
|
|
}
|
|
|
|
async function seedE2eInvocableSkills(userDataDir: string): Promise<void> {
|
|
const workspaceRoot = path.join(userDataDir, 'workspaces', 'default');
|
|
const projectRoot = path.join(userDataDir, 'project');
|
|
const projectSkillRoot = path.join(projectRoot, '.maka', 'skills');
|
|
const workspaceSkillRoot = path.join(workspaceRoot, 'skills');
|
|
// Under the sandboxed HOME (see buildE2eEnv), so `~/.agents/skills` here is
|
|
// the throwaway dir, never the developer's. This is the only user-scope
|
|
// skill the suite sees, which is what makes the delete journey assertable.
|
|
const userSkillRoot = path.join(userDataDir, 'home', '.agents', 'skills');
|
|
await Promise.all([
|
|
mkdir(path.join(projectSkillRoot, 'project-only'), { recursive: true }),
|
|
mkdir(path.join(projectSkillRoot, 'host-incompatible'), { recursive: true }),
|
|
mkdir(path.join(projectSkillRoot, 'agent-write'), { recursive: true }),
|
|
mkdir(path.join(projectSkillRoot, 'deep-research-only'), { recursive: true }),
|
|
mkdir(path.join(workspaceSkillRoot, 'workspace-only'), { recursive: true }),
|
|
mkdir(path.join(userSkillRoot, 'user-only'), { recursive: true }),
|
|
]);
|
|
await writeFile(
|
|
path.join(userSkillRoot, 'user-only', 'SKILL.md'),
|
|
`---\nname: User Only\ndescription: User-scoped install, deletable from the panel.\n---\n# User Only`,
|
|
'utf8',
|
|
);
|
|
await Promise.all([
|
|
writeFile(
|
|
path.join(projectSkillRoot, 'project-only', 'SKILL.md'),
|
|
`---\nname: Project Only\ndescription: Project-scoped suggestion.\n---\n# Project Only`,
|
|
'utf8',
|
|
),
|
|
writeFile(
|
|
path.join(projectSkillRoot, 'host-incompatible', 'SKILL.md'),
|
|
`---\nname: Host Incompatible\ndescription: Must be hidden from this host.\nrequired-tools: [DefinitelyMissingTool]\n---\n# Host Incompatible`,
|
|
'utf8',
|
|
),
|
|
writeFile(
|
|
path.join(projectSkillRoot, 'agent-write', 'SKILL.md'),
|
|
`---\nname: Agent Write\ndescription: Requires a mutating tool excluded from Plan mode.\nrequired-tools: [Write]\n---\n# Agent Write`,
|
|
'utf8',
|
|
),
|
|
writeFile(
|
|
path.join(projectSkillRoot, 'deep-research-only', 'SKILL.md'),
|
|
`---\nname: Deep Research Only\ndescription: Requires a tool available only in Deep Research mode.\nrequired-tools: [deep_research_status]\n---\n# Deep Research Only`,
|
|
'utf8',
|
|
),
|
|
writeFile(
|
|
path.join(workspaceSkillRoot, 'workspace-only', 'SKILL.md'),
|
|
`---\nname: Workspace Only\ndescription: Maka workspace suggestion.\n---\n# Workspace Only`,
|
|
'utf8',
|
|
),
|
|
]);
|
|
await seedCurrentProject(workspaceRoot, projectRoot);
|
|
}
|
|
|
|
async function seedE2eGitReviewProject(
|
|
userDataDir: string,
|
|
extraUntrackedFiles = 0,
|
|
): Promise<void> {
|
|
const workspaceRoot = path.join(userDataDir, 'workspaces', 'default');
|
|
const projectRoot = path.join(userDataDir, 'git-review-project');
|
|
await mkdir(projectRoot, { recursive: true });
|
|
const git = (...args: string[]) =>
|
|
execFileAsync('git', ['-C', projectRoot, ...args], {
|
|
encoding: 'utf8',
|
|
timeout: 10_000,
|
|
});
|
|
await git('init', '-b', 'main');
|
|
await git('config', 'user.name', 'Maka E2E');
|
|
await git('config', 'user.email', 'maka-e2e@example.invalid');
|
|
await writeFile(path.join(projectRoot, 'base.txt'), 'base\n', 'utf8');
|
|
await git('add', 'base.txt');
|
|
await git('commit', '-m', 'base');
|
|
await git('checkout', '-b', 'feature/review');
|
|
await writeFile(path.join(projectRoot, 'feature.txt'), 'feature\n', 'utf8');
|
|
await git('add', 'feature.txt');
|
|
await git('commit', '-m', 'feature');
|
|
await writeFile(path.join(projectRoot, 'base.txt'), 'base\nunstaged\n', 'utf8');
|
|
await writeFile(path.join(projectRoot, 'staged.txt'), 'staged\n', 'utf8');
|
|
await git('add', 'staged.txt');
|
|
await writeFile(path.join(projectRoot, 'untracked.txt'), 'untracked\n', 'utf8');
|
|
await Promise.all(
|
|
Array.from({ length: extraUntrackedFiles }, (_, index) =>
|
|
writeFile(
|
|
path.join(projectRoot, `bulk-${String(index).padStart(3, '0')}.txt`),
|
|
`bulk ${index}\n`,
|
|
'utf8',
|
|
),
|
|
),
|
|
);
|
|
await seedCurrentProject(workspaceRoot, projectRoot);
|
|
}
|
|
|
|
/**
|
|
* One registered Project and nothing else, so the workspace picker under the
|
|
* new-task composer offers two selectable targets: this Project and the Host's
|
|
* implicit "no project". The new-task draft slot is keyed by (profile, host,
|
|
* project), so moving between them is what re-keys it (#3408). The directory is
|
|
* plain — its basename becomes the Project name the picker menu shows.
|
|
*/
|
|
async function seedE2eNewTaskProject(userDataDir: string): Promise<void> {
|
|
const workspaceRoot = path.join(userDataDir, 'workspaces', 'default');
|
|
const projectRoot = path.join(userDataDir, NEW_TASK_PROJECT_NAME);
|
|
await mkdir(projectRoot, { recursive: true });
|
|
await seedCurrentProject(workspaceRoot, projectRoot);
|
|
}
|
|
|
|
async function seedCurrentProject(workspaceRoot: string, projectRoot: string): Promise<void> {
|
|
const storageRoot = await resolveStorageRoot({ path: workspaceRoot, kind: 'interactive' });
|
|
const catalog = createProjectCatalog(workspaceRoot);
|
|
try {
|
|
const project = await catalog.register(projectRoot);
|
|
await writeFile(
|
|
path.join(workspaceRoot, 'project-preferences.json'),
|
|
JSON.stringify({ version: 1, selections: { [storageRoot.rootId]: project.id } }),
|
|
'utf8',
|
|
);
|
|
} finally {
|
|
catalog.close();
|
|
}
|
|
}
|
|
|
|
/**
|
|
* Own the full launch lifecycle so a failure anywhere — seeding, Electron
|
|
* launch, firstWindow, or the readiness wait — still tears down the Electron
|
|
* process and the throwaway userData dir. The previous shape ran `mkdtemp`
|
|
* and `launchE2eApp` outside the try, so a readiness timeout left a zombie
|
|
* Electron and a leaked `maka-e2e-*` directory.
|
|
*/
|
|
async function withE2eWindow(
|
|
{
|
|
seed,
|
|
readinessSelector,
|
|
e2eFixtureScenario,
|
|
locale,
|
|
platform,
|
|
showWindow,
|
|
scrollMotion,
|
|
invocableSkills,
|
|
gitReviewExtraFiles,
|
|
parentRemovalSessions,
|
|
railRenderSessions,
|
|
newTaskProject,
|
|
}: {
|
|
seed: boolean;
|
|
readinessSelector: string;
|
|
e2eFixtureScenario?: string;
|
|
locale?: 'zh' | 'en';
|
|
/** Opt this window back into animated scrolling; see `scroll-motion-policy`. */
|
|
scrollMotion?: 'auto' | 'smooth';
|
|
/** #1312: force app:info's platform so the window boots natively into that platform's `data-os` cascade. */
|
|
platform?: 'darwin' | 'win32' | 'linux';
|
|
/** Show fixtures whose contract depends on compositor-paced frames. */
|
|
showWindow?: boolean;
|
|
invocableSkills?: boolean;
|
|
gitReviewExtraFiles?: number;
|
|
parentRemovalSessions?: boolean;
|
|
railRenderSessions?: boolean;
|
|
newTaskProject?: boolean;
|
|
},
|
|
use: (page: Page, context: { userDataDir: string; app: ElectronApplication }) => Promise<void>,
|
|
): Promise<void> {
|
|
const userDataDir = await mkdtemp(path.join(tmpdir(), 'maka-e2e-'));
|
|
// Lives inside the throwaway userData dir so the existing teardown removes
|
|
// it too — there is no second path to leak.
|
|
const homeDir = path.join(userDataDir, 'home');
|
|
await mkdir(homeDir, { recursive: true });
|
|
let app: ElectronApplication | undefined;
|
|
const mainLogs: string[] = [];
|
|
const rendererLogs: string[] = [];
|
|
try {
|
|
if (seed) await seedE2eConnection(userDataDir);
|
|
if (parentRemovalSessions) await seedParentRemovalSessions(userDataDir);
|
|
if (railRenderSessions) await seedRailRenderSessions(userDataDir);
|
|
if (invocableSkills) await seedE2eInvocableSkills(userDataDir);
|
|
if (gitReviewExtraFiles !== undefined) {
|
|
await seedE2eGitReviewProject(userDataDir, gitReviewExtraFiles);
|
|
}
|
|
if (newTaskProject) await seedE2eNewTaskProject(userDataDir);
|
|
// Legacy E2E specs assert Chinese labels and should not inherit the CI
|
|
// host locale. E2e-fixture workspaces use the explicit renderer override.
|
|
if (locale && !e2eFixtureScenario) await seedE2eLocale(userDataDir, locale);
|
|
app = await electron.launch({
|
|
args: ['.'],
|
|
cwd: DESKTOP_ROOT,
|
|
env: buildFixtureEnv(userDataDir, homeDir, {
|
|
scenario: e2eFixtureScenario,
|
|
locale,
|
|
platform,
|
|
scrollMotion,
|
|
// xvfb throttles a hidden window's compositor to ~1fps. Geometry
|
|
// fixtures opt in locally; every fixture is visible on isolated CI X.
|
|
showWindow: showWindow || isCiLinuxDisplay(),
|
|
}),
|
|
});
|
|
app.on('console', (message) => {
|
|
mainLogs.push(message.text());
|
|
if (mainLogs.length > 20) mainLogs.shift();
|
|
});
|
|
let page: Page;
|
|
try {
|
|
page = await app.firstWindow();
|
|
} catch (error) {
|
|
const detail = error instanceof Error ? error.message : String(error);
|
|
const logs = mainLogs.length > 0 ? `\nElectron main console:\n${mainLogs.join('\n')}` : '';
|
|
throw new Error(`${detail}${logs}`, { cause: error });
|
|
}
|
|
page.on('console', (message) => {
|
|
rendererLogs.push(`[console:${message.type()}] ${message.text()}`);
|
|
if (rendererLogs.length > 30) rendererLogs.shift();
|
|
});
|
|
page.on('pageerror', (error) => {
|
|
rendererLogs.push(`[pageerror] ${error.stack ?? error.message}`);
|
|
if (rendererLogs.length > 30) rendererLogs.shift();
|
|
});
|
|
// Centralize the cold-start wait so test bodies are flake-free under retries:0.
|
|
try {
|
|
await page.waitForSelector(readinessSelector, { timeout: 20_000 });
|
|
if (invocableSkills) {
|
|
await waitForInvocableSkills(page, ['project-only', 'workspace-only']);
|
|
}
|
|
} catch (error) {
|
|
const detail = error instanceof Error ? error.message : String(error);
|
|
const mainDetail = mainLogs.length > 0 ? `\nElectron main console:\n${mainLogs.join('\n')}` : '';
|
|
const rendererDetail = rendererLogs.length > 0 ? `\nRenderer console:\n${rendererLogs.join('\n')}` : '';
|
|
throw new Error(`${detail}${mainDetail}${rendererDetail}`, { cause: error });
|
|
}
|
|
await use(page, { userDataDir, app });
|
|
} finally {
|
|
try {
|
|
if (app) await closeElectronApplication(app, 5_000);
|
|
} finally {
|
|
await rm(userDataDir, { recursive: true, force: true });
|
|
}
|
|
}
|
|
}
|
|
|
|
export const test = base.extend<{
|
|
window: Page;
|
|
onboardingWindow: Page;
|
|
gitReviewWindow: { page: Page; projectRoot: string };
|
|
invocableSkillsWindow: Page;
|
|
linkColorWindow: Page;
|
|
projectSidebarWindow: Page;
|
|
parentRemovalWindow: Page;
|
|
railRenderWindow: Page;
|
|
promptRailWindow: Page;
|
|
partialHistoryWindow: Page;
|
|
promptRailMotionWindow: Page;
|
|
requestHeaderRowWindow: Page;
|
|
newTaskTargetWindow: Page;
|
|
directoryReferenceWindow: { page: Page; folder: string };
|
|
accessibilityNarrativeWindow: Page;
|
|
}>({
|
|
directoryReferenceWindow: async ({}, use) => {
|
|
await withE2eWindow(
|
|
{ seed: true, readinessSelector: COMPOSER_INPUT, locale: 'zh', showWindow: true },
|
|
async (page, { userDataDir, app }) => {
|
|
const folder = path.join(userDataDir, 'referenced-source');
|
|
await mkdir(path.join(folder, 'nested'), { recursive: true });
|
|
await writeFile(path.join(folder, 'README.md'), 'DO_NOT_READ_FILE_CONTENTS');
|
|
await writeFile(path.join(folder, 'nested', 'deep.txt'), 'DO_NOT_DESCEND');
|
|
// Replace only the OS chooser. IPC, Host admission, message delivery,
|
|
// event persistence and rendering still run through the real stack.
|
|
await app.evaluate(({ dialog }, selectedPath) => {
|
|
dialog.showOpenDialog = async () => ({ canceled: false, filePaths: [selectedPath] });
|
|
}, folder);
|
|
await use({ page, folder });
|
|
},
|
|
);
|
|
},
|
|
// Seeded: a pre-staged connection clears onboarding so the composer is ready.
|
|
window: async ({}, use) => {
|
|
await withE2eWindow({ seed: true, readinessSelector: COMPOSER_INPUT, locale: 'zh' }, use);
|
|
},
|
|
onboardingWindow: async ({}, use) => {
|
|
await withE2eWindow({
|
|
seed: false,
|
|
readinessSelector: '[data-maka-contract="onboarding-card"]',
|
|
locale: 'zh',
|
|
showWindow: true,
|
|
}, use);
|
|
},
|
|
gitReviewWindow: async ({}, use) => {
|
|
await withE2eWindow(
|
|
{
|
|
seed: true,
|
|
readinessSelector: COMPOSER_INPUT,
|
|
locale: 'zh',
|
|
gitReviewExtraFiles: 0,
|
|
},
|
|
async (page, context) => {
|
|
await use({
|
|
page,
|
|
projectRoot: path.join(context.userDataDir, 'git-review-project'),
|
|
});
|
|
},
|
|
);
|
|
},
|
|
// Project + workspace Skills for draft/chip journeys.
|
|
invocableSkillsWindow: async ({}, use) => {
|
|
await withE2eWindow({
|
|
seed: true,
|
|
readinessSelector: COMPOSER_INPUT,
|
|
locale: 'zh',
|
|
invocableSkills: true,
|
|
}, use);
|
|
},
|
|
linkColorWindow: async ({}, use) => {
|
|
await withE2eWindow({
|
|
seed: false,
|
|
readinessSelector: '.settingsBotConfigDocLink',
|
|
e2eFixtureScenario: 'settings-bots-onboarding',
|
|
}, use);
|
|
},
|
|
// A real project with several sessions. Shown because the contract under
|
|
// test is native focus order across independently interactive row controls.
|
|
// Seeded connection so the composer is ready, plus one registered Project so
|
|
// the workspace picker under it has a second target to move to.
|
|
newTaskTargetWindow: async ({}, use) => {
|
|
await withE2eWindow({
|
|
seed: true,
|
|
readinessSelector: COMPOSER_INPUT,
|
|
locale: 'zh',
|
|
newTaskProject: true,
|
|
showWindow: true,
|
|
}, use);
|
|
},
|
|
projectSidebarWindow: async ({}, use) => {
|
|
await withE2eWindow({
|
|
seed: false,
|
|
readinessSelector: '[data-maka-contract="search-modal"][open]',
|
|
e2eFixtureScenario: 'sidebar-search-modal-open',
|
|
locale: 'zh',
|
|
showWindow: true,
|
|
}, use);
|
|
},
|
|
parentRemovalWindow: async ({}, use) => {
|
|
await withE2eWindow(
|
|
{
|
|
seed: true,
|
|
readinessSelector: COMPOSER_INPUT,
|
|
locale: 'zh',
|
|
parentRemovalSessions: true,
|
|
},
|
|
use,
|
|
);
|
|
},
|
|
railRenderWindow: async ({}, use) => {
|
|
await withE2eWindow(
|
|
{
|
|
seed: true,
|
|
readinessSelector: COMPOSER_INPUT,
|
|
locale: 'zh',
|
|
railRenderSessions: true,
|
|
},
|
|
use,
|
|
);
|
|
},
|
|
// A multi-prompt transcript for the prompt anchor rail. Shown, because every
|
|
// assertion in prompt-rail.spec.ts is geometry the compositor has to settle.
|
|
promptRailWindow: async ({}, use) => {
|
|
await withE2eWindow({
|
|
seed: false,
|
|
// A rendered turn, deliberately not the rail: Playwright treats a
|
|
// zero-area element as hidden, so gating readiness on a tick would turn
|
|
// every rail regression into a 20s cold-start timeout instead of the
|
|
// assertion that names it.
|
|
readinessSelector: '[data-turn-id]',
|
|
e2eFixtureScenario: 'chat-prompt-rail',
|
|
showWindow: true,
|
|
}, use);
|
|
},
|
|
// A transcript larger than the bounded Desktop range. Clicking an unloaded
|
|
// prompt exercises the real load-around path and its partial-history UI.
|
|
partialHistoryWindow: async ({}, use) => {
|
|
await withE2eWindow({
|
|
seed: false,
|
|
readinessSelector: '[data-turn-id]',
|
|
e2eFixtureScenario: 'chat-partial-history',
|
|
locale: 'zh',
|
|
showWindow: true,
|
|
}, use);
|
|
},
|
|
// The same transcript, scrolling the way the shipped app scrolls. Separate
|
|
// from `promptRailWindow` because it is only the jump that needs a scroll
|
|
// still in flight, and paying for one everywhere costs several seconds per
|
|
// window and settles less predictably.
|
|
promptRailMotionWindow: async ({}, use) => {
|
|
await withE2eWindow({
|
|
seed: false,
|
|
readinessSelector: '[data-turn-id]',
|
|
e2eFixtureScenario: 'chat-prompt-rail',
|
|
showWindow: true,
|
|
scrollMotion: 'smooth',
|
|
}, use);
|
|
},
|
|
// Settings → 模型, where `no-models` is the seeded openai-compatible relay —
|
|
// the connection type whose detail page owns the custom request headers
|
|
// editor. Shown, because what this window is for is a rendered box
|
|
// measurement and a throttled compositor is not a layout the user has.
|
|
requestHeaderRowWindow: async ({}, use) => {
|
|
await withE2eWindow({
|
|
seed: false,
|
|
readinessSelector: '.settingsSurface',
|
|
e2eFixtureScenario: 'settings-models',
|
|
locale: 'zh',
|
|
showWindow: true,
|
|
}, use);
|
|
},
|
|
// A data-backed conversation with settled tool evidence and a populated
|
|
// task ledger. Shown because the accessibility journey follows real native
|
|
// focus order through the transcript into the composer controls.
|
|
accessibilityNarrativeWindow: async ({}, use) => {
|
|
await withE2eWindow({
|
|
seed: false,
|
|
readinessSelector: '[data-turn-id]',
|
|
e2eFixtureScenario: 'turn-narrative',
|
|
locale: 'zh',
|
|
showWindow: true,
|
|
}, use);
|
|
},
|
|
});
|
|
|
|
export { expect };
|