feat: 将发布预检移入内置浏览器
This commit is contained in:
@@ -0,0 +1,77 @@
|
||||
# Task: 将静态作品浏览器预检移入 Makelore
|
||||
|
||||
## Identity
|
||||
|
||||
- Task ID: 20260812-client-browser-preflight-c8e2
|
||||
- Mode: Feature
|
||||
- Branch: codex/20260812-client-browser-preflight-c8e2-browser-preflight
|
||||
- Worktree: D:\Datas\OthersProjects\makelore-browser-preflight-c8e2
|
||||
- Base commit: be9bc84474cd23aa3861c0cb059082785e72abf2
|
||||
- Owner: codex
|
||||
- Status: Completed
|
||||
|
||||
## Scope
|
||||
|
||||
- 在唯一发布链路 `ProjectPublishAction -> publishWorksProjectSource -> createStaticProjectPackage` 上传前,检查当前项目已在内置浏览器运行的本地预览。
|
||||
- 复用 Main-owned `AgentBrowserModule` / `ElectronAgentBrowserAdapter` 和 Electron 自带 Chromium,对同一受信回环 URL 执行桌面与移动视口预检。
|
||||
- 捕获主文档加载失败、页面异常、console/log error、失败资源、跨源请求/跳转和白屏;使用单一全局 deadline,并总是销毁临时视图、清空临时 partition。
|
||||
- Renderer 仅接收固定错误码与中文操作提示,不接收本地路径、构建输出或浏览器事件细节。
|
||||
- 更新聚焦单元测试、Electron E2E、README 和本任务记录;不修改 canonical `.project-docs`。
|
||||
|
||||
## Intent And Constraints
|
||||
|
||||
- 不新增 Playwright、Chromium 或其他浏览器下载依赖;不创建平行 BrowserWindow 能力。
|
||||
- 预检使用非持久临时 partition 和不挂载 UI 的 WebContentsView,不污染用户浏览器标签、历史、cookie、localStorage 或登录态。
|
||||
- 不运行项目自定义脚本、不执行 Vite 配置、不在线安装依赖;没有受信本地预览时安全失败并提示先在内置浏览器打开预览。
|
||||
- 客户端预检仅改善提交前反馈,不写入上传字段、不构成信任声明;服务端继续独立构建、包体校验和发布门禁。
|
||||
- 仅在隔离 feature worktree 修改本任务拥有的客户端生产、测试、README 和任务记录文件。
|
||||
|
||||
## Project Context Loaded
|
||||
|
||||
- Task identity / ownership: `20260812-client-browser-preflight-c8e2`, feature,branch/worktree/base 均与 task registry 一致。
|
||||
- Integrated snapshot: 发布唯一链路、Renderer/Main 安全边界和服务端独立验收已集成至 `be9bc844`。
|
||||
- Relevant architecture: `electron/agent-browser` 已提供 sandboxed WebContentsView、CDP、权限拒绝与 partition 清理;`electron/api/routes/works.ts` 在 Main 内完成打包上传。
|
||||
- Peer overlap: 其他本地任务均在独立 worktree 且 ready_for_integration;与本任务生产文件无并发写入重叠。
|
||||
- Planning Gate: Passed.
|
||||
|
||||
## Plan
|
||||
|
||||
1. 用失败测试固定内置浏览器临时 partition、双视口、错误捕获与 cleanup 契约。
|
||||
2. 增加基于当前项目 loopback URL 的临时双视口 probe,并在上传前接入 Works 发布路由。
|
||||
3. 增加安全错误投影和 Renderer 文案,确认响应不暴露路径或底层日志。
|
||||
4. 更新 README,执行 typecheck、聚焦单测、构建和 Electron E2E。
|
||||
5. 完成任务文档门禁并提交中文 feature commit。
|
||||
|
||||
## Outcome
|
||||
|
||||
- 在 `AgentBrowserModule` 内新增 `local_preview_preflight` seam:只接受当前项目已 attached 的显式端口 HTTP loopback URL,不接受远程地址、HTTPS、userinfo、无端口或其他项目。
|
||||
- 每个桌面/移动视口创建一个 fresh 非持久 partition、未挂载的 sandboxed `WebContentsView`;先 prime `about:blank`,再用 CDP 设置设备指标并加载同一预览源。
|
||||
- 预检捕获主文档加载失败、renderer fault、Runtime exception/error Console、Log error、HTTP >=400、非预览源 HTTP(S) 请求、非取消资源失败、跨源顶层跳转和白屏/零尺寸画布;使用单一 30 秒 deadline。
|
||||
- cleanup 覆盖视图创建后的全部 setup/listener;无论成功失败都移除监听、detach、destroy,并以 1 秒上限 best-effort 清理临时 partition。用户持久浏览器 record/profile/历史/登录态不变。
|
||||
- Works 一键提交在安全打包和网络请求前调用预检;没有活动预览时返回固定 `PREVIEW_REQUIRED`。Renderer 只显示 allowlist 错误码和中文操作提示,不接收 URL、路径、事件或页面内容。
|
||||
- 未增加任何浏览器/Playwright 下载依赖,未执行项目构建或安装依赖,未生成或上传预检 receipt;服务端继续独立构建和校验。
|
||||
- README 与提交状态文案已改为“本地预览检查 -> 上传 -> 云端受控构建与平台校验”。
|
||||
|
||||
## Verification
|
||||
|
||||
- `pnpm exec vitest run tests/unit/agent-browser-electron-adapter.test.ts tests/unit/agent-browser-core.test.ts tests/unit/works-routes.test.ts tests/unit/works-project-publish.test.ts tests/unit/project-publish-action.test.tsx`: 5 files / 121 tests passed。
|
||||
- `pnpm run typecheck`: passed。
|
||||
- scoped ESLint(本任务全部 TS/TSX): passed。
|
||||
- `pnpm run build:vite`: Renderer、Electron Main、Preload 均构建成功;仅仓库既有 chunk/dynamic-import warning。
|
||||
- `pnpm exec playwright test tests/e2e/local-preview-preflight.spec.ts --reporter=line`: 1 passed;真实 Electron 通过生产 `AgentBrowserModule.preflightCurrentProject` seam 完成桌面/移动双视口,证明临时 probe 的外域请求在发送前被拦截且 WebContents 数量恢复。
|
||||
- `pnpm exec playwright test --list --reporter=line`: E2E 收集成功(新增测试前为 14 files / 22 tests;新增 spec 已单独运行)。
|
||||
- `git diff --check`: passed(仅 Git LF/CRLF 工作区提示)。
|
||||
|
||||
## Follow-ups
|
||||
|
||||
- 集成后用真实项目运行本地预览,执行“预览 -> 提交 -> 云端 Builder -> 运营审核 -> App 播放”生产整链验收;客户端预检不替代该验收。
|
||||
- 当前预检验证的是用户当前已运行的本地预览 URL,而非随后上传源码的确定性构建输出;服务端独立重建是必要信任边界。
|
||||
|
||||
## Promotion Candidates
|
||||
|
||||
- Target: `.project-docs/20-architecture/system-overview.md`、`module-map.md`、`data-flow.md`、`.project-docs/40-domain/business-rules.md`。
|
||||
- Proposal: 一键提交前由 Main 复用当前项目内置浏览器的受信 loopback URL,在 fresh 非持久、未挂载 Electron Chromium view 中执行本地预览 UX preflight;没有活动预览时 fail closed。客户端不构建、不安装浏览器/依赖、不上传通过声明,服务端仍独立重建与校验。
|
||||
- Evidence: 121 项聚焦回归、typecheck、scoped lint、三端 Vite 构建和真实 Electron 生产 preflight seam E2E 通过。
|
||||
- Future impact: 后续不得把客户端预检改成桌面权限执行项目 Vite/config/plugin,也不得重新把 Playwright/Chromium 下载装入 Builder;若要验证上传源码对应输出,应在服务端安全沙箱或建立新的受控构建契约。
|
||||
- Semantic conflicts: canonical 当前仍称“客户端提交 -> 云构建 -> 浏览器门禁”;需澄清客户端 local preview preflight 与服务端独立构建/平台校验的边界,并删除对服务端 Playwright smoke 的默认依赖描述。
|
||||
- Human confirmation required: 是;由 Integration Gate 统一提升。
|
||||
@@ -92,6 +92,8 @@ Windows 打包脚本会先准备目标架构所需的 Python、uv 与 OpenCode
|
||||
- Electron Main 持有 sandboxed `WebContentsView`、项目级持久浏览器配置和 CDP 连接;被调试页面不获得 Makelore Preload、Node.js 能力或 Host API 凭证。
|
||||
- 用户和 Agent 操作同一个页面。Renderer 只负责显示、收起和布局;Agent 通过 Main 代理的页面级 CDP 工具导航、读取 Console/Network 和执行调试命令。
|
||||
- 非 Web 协议、文件注入、跨目标及宿主级命令会被阻止。面板收起或被弹窗遮挡时隐藏原生页面并暂停 Agent 调试;该能力独立于发布和部署。
|
||||
- 一键提交前,Makelore 会复用当前项目已在内置浏览器打开的本地回环预览地址,并在两个独立的临时 Chromium profile 中检查桌面和移动视口的主页面加载、运行错误、失败资源与白屏。临时页面不挂载到界面,不读取或写入用户浏览器的 Cookie、历史和登录态;检查结束后始终销毁并清理。
|
||||
- 客户端不会为此安装 Playwright/Chromium、下载依赖或执行项目构建脚本。没有活动的本地预览时会提示先在内置浏览器打开预览;该检查只改善提交前反馈,服务端仍独立重建、校验和发布,客户端不会上传“已通过”声明。
|
||||
|
||||
### 真机预览
|
||||
|
||||
|
||||
@@ -32,6 +32,7 @@ export interface AgentBrowserWebContentsPort {
|
||||
isDestroyed(): boolean;
|
||||
isDevToolsOpened(): boolean;
|
||||
reload(): void;
|
||||
executeJavaScript(code: string): Promise<unknown>;
|
||||
denyWindowOpen(): void;
|
||||
on(event: string, listener: PortListener): void;
|
||||
removeListener(event: string, listener: PortListener): void;
|
||||
@@ -49,4 +50,5 @@ export interface AgentBrowserAdapter {
|
||||
unmount(view: AgentBrowserViewPort): void;
|
||||
destroy(view: AgentBrowserViewPort): void;
|
||||
resetPartition(partition: string): Promise<void>;
|
||||
restrictPartitionToOrigin(partition: string, origin: string): () => void;
|
||||
}
|
||||
|
||||
@@ -67,6 +67,7 @@ function wrapWebContents(contents: WebContents): AgentBrowserWebContentsPort {
|
||||
isDestroyed: () => contents.isDestroyed(),
|
||||
isDevToolsOpened: () => contents.isDevToolsOpened(),
|
||||
reload: () => contents.reload(),
|
||||
executeJavaScript: (code) => contents.executeJavaScript(code),
|
||||
denyWindowOpen: () => {
|
||||
contents.setWindowOpenHandler(() => ({ action: 'deny' }));
|
||||
},
|
||||
@@ -151,6 +152,31 @@ export class ElectronAgentBrowserAdapter implements AgentBrowserAdapter {
|
||||
await browserSession.clearCache();
|
||||
}
|
||||
|
||||
restrictPartitionToOrigin(partition: string, origin: string): () => void {
|
||||
const browserSession = session.fromPartition(partition);
|
||||
const listener = (
|
||||
details: Electron.OnBeforeRequestListenerDetails,
|
||||
callback: (response: Electron.CallbackResponse) => void,
|
||||
) => {
|
||||
try {
|
||||
const requestUrl = new URL(details.url);
|
||||
const localDocumentScheme = requestUrl.protocol === 'about:'
|
||||
|| requestUrl.protocol === 'data:'
|
||||
|| requestUrl.protocol === 'blob:';
|
||||
callback({
|
||||
cancel: !localDocumentScheme
|
||||
&& !(requestUrl.protocol === 'http:' && requestUrl.origin === origin),
|
||||
});
|
||||
} catch {
|
||||
callback({ cancel: true });
|
||||
}
|
||||
};
|
||||
browserSession.webRequest.onBeforeRequest({ urls: ['*://*/*'] }, listener);
|
||||
return () => {
|
||||
browserSession.webRequest.onBeforeRequest(null);
|
||||
};
|
||||
}
|
||||
|
||||
private requireNativeView(view: AgentBrowserViewPort): WebContentsView {
|
||||
const nativeView = this.nativeViews.get(view);
|
||||
if (!nativeView) throw new Error('Unknown Agent Browser view.');
|
||||
|
||||
@@ -27,6 +27,53 @@ const DEFAULT_CDP_TIMEOUT_MS = 10_000;
|
||||
const MAX_CDP_TIMEOUT_MS = 30_000;
|
||||
const OPEN_TIMEOUT_MS = 30_000;
|
||||
const RENDERER_PRIME_URL = 'about:blank';
|
||||
const PUBLISH_PREFLIGHT_TIMEOUT_MS = 30_000;
|
||||
const PUBLISH_PREFLIGHT_SETTLE_MS = 500;
|
||||
const PUBLISH_PREFLIGHT_VIEWPORTS = [
|
||||
{ width: 1280, height: 720 },
|
||||
{ width: 390, height: 844 },
|
||||
] as const;
|
||||
const PUBLISH_PREFLIGHT_INSPECTION = `(() => {
|
||||
const body = document.body;
|
||||
if (!body) return { readyState: document.readyState, visible: false };
|
||||
const candidates = Array.from(body.querySelectorAll('*')).slice(0, 2000);
|
||||
const visible = candidates.some((element) => {
|
||||
const style = getComputedStyle(element);
|
||||
if (style.display === 'none' || style.visibility === 'hidden' || Number(style.opacity) === 0) return false;
|
||||
const rect = element.getBoundingClientRect();
|
||||
if (rect.width <= 1 || rect.height <= 1) return false;
|
||||
if (element instanceof HTMLCanvasElement) {
|
||||
const scale = Math.min(rect.width / Math.max(element.width, 1), rect.height / Math.max(element.height, 1));
|
||||
return element.width > 1 && element.height > 1 && scale >= 0.15;
|
||||
}
|
||||
if (element instanceof HTMLImageElement) return element.complete && element.naturalWidth > 1;
|
||||
if (element instanceof HTMLVideoElement) return element.readyState >= 2;
|
||||
if (element instanceof SVGElement) return true;
|
||||
return Boolean(element.textContent?.trim())
|
||||
|| style.backgroundImage !== 'none';
|
||||
});
|
||||
return {
|
||||
readyState: document.readyState,
|
||||
visible: visible || Boolean(body.innerText?.trim()),
|
||||
viewport: { width: window.innerWidth, height: window.innerHeight },
|
||||
};
|
||||
})()`;
|
||||
const SAFE_PREFLIGHT_MESSAGES = {
|
||||
PREVIEW_REQUIRED: '请先在 Makelore 内置浏览器中打开当前项目预览。',
|
||||
PUBLISH_PREFLIGHT_LOAD_FAILED: '作品主页无法打开。',
|
||||
PUBLISH_PREFLIGHT_RUNTIME_ERROR: '作品打开时发生了运行错误。',
|
||||
PUBLISH_PREFLIGHT_BLANK: '作品打开后没有可见内容。',
|
||||
PUBLISH_PREFLIGHT_TIMEOUT: '作品打开检查超时。',
|
||||
} as const;
|
||||
|
||||
class PublishPreflightFault extends Error {
|
||||
constructor(
|
||||
readonly code: keyof typeof SAFE_PREFLIGHT_MESSAGES,
|
||||
) {
|
||||
super(SAFE_PREFLIGHT_MESSAGES[code]);
|
||||
this.name = 'PublishPreflightFault';
|
||||
}
|
||||
}
|
||||
const ENABLED_DOMAINS = ['Runtime.enable', 'Log.enable', 'Network.enable', 'Page.enable'] as const;
|
||||
const STREAM_ISSUING_METHODS = new Set([
|
||||
'Fetch.takeResponseBodyAsStream',
|
||||
@@ -136,6 +183,25 @@ export class AgentBrowserModule {
|
||||
this.cdpGuard = options.cdpGuard ?? new AgentBrowserCdpGuard();
|
||||
}
|
||||
|
||||
async preflightCurrentProject(projectPath: string): Promise<{ ok: true }> {
|
||||
this.assertAvailable();
|
||||
let record: BrowserRecord;
|
||||
try {
|
||||
record = this.requireRecord(projectPath);
|
||||
if (record.state !== 'attached' || !record.view.webContents.debugger.isAttached()) {
|
||||
throw new Error('preview-not-attached');
|
||||
}
|
||||
} catch {
|
||||
throw new PublishPreflightFault('PREVIEW_REQUIRED');
|
||||
}
|
||||
const targetUrl = normalizeLoopbackPreviewUrl(record.url);
|
||||
const deadline = Date.now() + PUBLISH_PREFLIGHT_TIMEOUT_MS;
|
||||
for (const viewport of PUBLISH_PREFLIGHT_VIEWPORTS) {
|
||||
await this.preflightStaticViewport(targetUrl, viewport, deadline);
|
||||
}
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
async getSnapshot(projectPath?: string): Promise<AgentBrowserSnapshot> {
|
||||
if (!this.record) return this.closedSnapshot();
|
||||
if (projectPath) this.assertProject(this.record, projectPath);
|
||||
@@ -420,6 +486,135 @@ export class AgentBrowserModule {
|
||||
this.payloadStore.clear();
|
||||
}
|
||||
|
||||
private async preflightStaticViewport(
|
||||
targetUrl: string,
|
||||
viewport: { width: number; height: number },
|
||||
deadline: number,
|
||||
): Promise<void> {
|
||||
const partition = `niancode-publish-preflight:${randomUUID()}`;
|
||||
const previewOrigin = new URL(targetUrl).origin;
|
||||
let view: AgentBrowserViewPort | null = null;
|
||||
let releaseOriginRestriction: (() => void) | null = null;
|
||||
let runtimeError = false;
|
||||
const onDebuggerMessage: PortListener = (_event, methodValue, paramsValue) => {
|
||||
if (typeof methodValue !== 'string') return;
|
||||
const params = isRecord(paramsValue) ? paramsValue : {};
|
||||
if (methodValue === 'Runtime.exceptionThrown') {
|
||||
runtimeError = true;
|
||||
} else if (methodValue === 'Network.loadingFailed') {
|
||||
const errorText = typeof params.errorText === 'string' ? params.errorText : '';
|
||||
const canceled = params.canceled === true || errorText === 'net::ERR_ABORTED';
|
||||
if (!canceled) runtimeError = true;
|
||||
} else if (methodValue === 'Runtime.consoleAPICalled' && params.type === 'error') {
|
||||
runtimeError = true;
|
||||
} else if (methodValue === 'Log.entryAdded') {
|
||||
const entry = isRecord(params.entry) ? params.entry : {};
|
||||
if (entry.level === 'error') runtimeError = true;
|
||||
} else if (methodValue === 'Network.responseReceived') {
|
||||
const response = isRecord(params.response) ? params.response : {};
|
||||
if (typeof response.status === 'number' && response.status >= 400) runtimeError = true;
|
||||
} else if (methodValue === 'Network.requestWillBeSent') {
|
||||
const request = isRecord(params.request) ? params.request : {};
|
||||
if (typeof request.url !== 'string') return;
|
||||
try {
|
||||
const requestUrl = new URL(request.url);
|
||||
if (
|
||||
(requestUrl.protocol === 'http:' || requestUrl.protocol === 'https:')
|
||||
&& requestUrl.origin !== previewOrigin
|
||||
) runtimeError = true;
|
||||
} catch {
|
||||
runtimeError = true;
|
||||
}
|
||||
}
|
||||
};
|
||||
const onDidFailLoad: PortListener = (
|
||||
_event, errorCodeValue, errorDescriptionValue, _url, isMainFrameValue,
|
||||
) => {
|
||||
if (
|
||||
isMainFrameValue !== false
|
||||
&& errorCodeValue !== -3
|
||||
&& errorDescriptionValue !== 'ERR_ABORTED'
|
||||
) runtimeError = true;
|
||||
};
|
||||
const onRendererFault: PortListener = () => {
|
||||
runtimeError = true;
|
||||
};
|
||||
|
||||
try {
|
||||
view = this.adapter.createView(partition);
|
||||
releaseOriginRestriction = this.adapter.restrictPartitionToOrigin(partition, previewOrigin);
|
||||
view.webContents.debugger.on('message', onDebuggerMessage);
|
||||
view.webContents.on('did-fail-load', onDidFailLoad);
|
||||
view.webContents.on('render-process-gone', onRendererFault);
|
||||
view.webContents.on('unresponsive', onRendererFault);
|
||||
view.webContents.on('destroyed', onRendererFault);
|
||||
view.webContents.denyWindowOpen();
|
||||
view.setBounds({ x: 0, y: 0, ...viewport });
|
||||
view.setVisible(false);
|
||||
await beforePublishPreflightDeadline(view.webContents.loadURL(RENDERER_PRIME_URL), deadline);
|
||||
view.webContents.debugger.attach(CDP_PROTOCOL_VERSION);
|
||||
await beforePublishPreflightDeadline(view.webContents.debugger.sendCommand('Runtime.enable'), deadline);
|
||||
await beforePublishPreflightDeadline(view.webContents.debugger.sendCommand('Log.enable'), deadline);
|
||||
await beforePublishPreflightDeadline(view.webContents.debugger.sendCommand('Network.enable'), deadline);
|
||||
await beforePublishPreflightDeadline(view.webContents.debugger.sendCommand('Page.enable'), deadline);
|
||||
await beforePublishPreflightDeadline(
|
||||
view.webContents.debugger.sendCommand('Emulation.setDeviceMetricsOverride', {
|
||||
width: viewport.width,
|
||||
height: viewport.height,
|
||||
deviceScaleFactor: 1,
|
||||
mobile: viewport.width < 600,
|
||||
screenWidth: viewport.width,
|
||||
screenHeight: viewport.height,
|
||||
scale: 1,
|
||||
}),
|
||||
deadline,
|
||||
);
|
||||
await beforePublishPreflightDeadline(view.webContents.loadURL(targetUrl), deadline);
|
||||
await beforePublishPreflightDeadline(delay(PUBLISH_PREFLIGHT_SETTLE_MS), deadline);
|
||||
const inspected = await beforePublishPreflightDeadline(
|
||||
view.webContents.executeJavaScript(PUBLISH_PREFLIGHT_INSPECTION),
|
||||
deadline,
|
||||
);
|
||||
if (new URL(view.webContents.getURL()).origin !== previewOrigin) {
|
||||
throw new PublishPreflightFault('PUBLISH_PREFLIGHT_LOAD_FAILED');
|
||||
}
|
||||
if (runtimeError) {
|
||||
throw new PublishPreflightFault('PUBLISH_PREFLIGHT_RUNTIME_ERROR');
|
||||
}
|
||||
const inspectedViewport = isRecord(inspected) && isRecord(inspected.viewport)
|
||||
? inspected.viewport
|
||||
: {};
|
||||
if (
|
||||
!isRecord(inspected)
|
||||
|| inspected.readyState !== 'complete'
|
||||
|| inspected.visible !== true
|
||||
|| !viewportMatches(inspectedViewport.width, viewport.width)
|
||||
|| !viewportMatches(inspectedViewport.height, viewport.height)
|
||||
) {
|
||||
throw new PublishPreflightFault('PUBLISH_PREFLIGHT_BLANK');
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof PublishPreflightFault) throw error;
|
||||
throw new PublishPreflightFault(
|
||||
isTimeoutError(error) ? 'PUBLISH_PREFLIGHT_TIMEOUT' : 'PUBLISH_PREFLIGHT_LOAD_FAILED',
|
||||
);
|
||||
} finally {
|
||||
bestEffortCleanup(() => view?.webContents.debugger.removeListener('message', onDebuggerMessage));
|
||||
bestEffortCleanup(() => view?.webContents.removeListener('did-fail-load', onDidFailLoad));
|
||||
bestEffortCleanup(() => view?.webContents.removeListener('render-process-gone', onRendererFault));
|
||||
bestEffortCleanup(() => view?.webContents.removeListener('unresponsive', onRendererFault));
|
||||
bestEffortCleanup(() => view?.webContents.removeListener('destroyed', onRendererFault));
|
||||
bestEffortCleanup(() => {
|
||||
if (view?.webContents.debugger.isAttached()) view.webContents.debugger.detach();
|
||||
});
|
||||
bestEffortCleanup(() => {
|
||||
if (view) this.adapter.destroy(view);
|
||||
});
|
||||
bestEffortCleanup(() => releaseOriginRestriction?.());
|
||||
await bestEffortPartitionReset(this.adapter, partition);
|
||||
}
|
||||
}
|
||||
|
||||
private async attachDebugger(record: BrowserRecord, reattach: boolean): Promise<void> {
|
||||
if (record !== this.record || record.view.webContents.isDestroyed()) {
|
||||
throw new AgentBrowserFault(
|
||||
@@ -1199,6 +1394,89 @@ function normalizeUrl(value: string): string {
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
async function beforePublishPreflightDeadline<T>(
|
||||
operation: Promise<T>,
|
||||
deadline: number,
|
||||
): Promise<T> {
|
||||
const remaining = deadline - Date.now();
|
||||
if (remaining <= 0) throw new Error('PUBLISH_PREFLIGHT_TIMEOUT');
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
return await Promise.race([
|
||||
operation,
|
||||
new Promise<T>((_resolve, reject) => {
|
||||
timer = setTimeout(() => reject(new Error('PUBLISH_PREFLIGHT_TIMEOUT')), remaining);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function delay(milliseconds: number): Promise<void> {
|
||||
return new Promise((resolvePromise) => {
|
||||
setTimeout(resolvePromise, milliseconds);
|
||||
});
|
||||
}
|
||||
|
||||
function isTimeoutError(error: unknown): boolean {
|
||||
return error instanceof Error && error.message === 'PUBLISH_PREFLIGHT_TIMEOUT';
|
||||
}
|
||||
|
||||
function viewportMatches(actual: unknown, expected: number): boolean {
|
||||
return typeof actual === 'number'
|
||||
&& Number.isFinite(actual)
|
||||
&& Math.abs(actual - expected) <= Math.max(2, expected * 0.05);
|
||||
}
|
||||
|
||||
function bestEffortCleanup(cleanup: () => void): void {
|
||||
try {
|
||||
cleanup();
|
||||
} catch {
|
||||
// Every subsequent cleanup step must still run for the temporary renderer.
|
||||
}
|
||||
}
|
||||
|
||||
async function bestEffortPartitionReset(
|
||||
adapter: AgentBrowserAdapter,
|
||||
partition: string,
|
||||
): Promise<void> {
|
||||
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||
try {
|
||||
await Promise.race([
|
||||
adapter.resetPartition(partition).catch(() => undefined),
|
||||
new Promise<void>((resolvePromise) => {
|
||||
timer = setTimeout(resolvePromise, 1_000);
|
||||
}),
|
||||
]);
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
function normalizeLoopbackPreviewUrl(value: string): string {
|
||||
let url: URL;
|
||||
try {
|
||||
url = new URL(value);
|
||||
} catch {
|
||||
throw new PublishPreflightFault('PREVIEW_REQUIRED');
|
||||
}
|
||||
const hostname = url.hostname.toLowerCase();
|
||||
const loopback = hostname === 'localhost'
|
||||
|| hostname === '127.0.0.1'
|
||||
|| hostname === '[::1]';
|
||||
if (
|
||||
!loopback
|
||||
|| url.protocol !== 'http:'
|
||||
|| !url.port
|
||||
|| url.username
|
||||
|| url.password
|
||||
) {
|
||||
throw new PublishPreflightFault('PREVIEW_REQUIRED');
|
||||
}
|
||||
return url.toString();
|
||||
}
|
||||
|
||||
function normalizeBounds(bounds: AgentBrowserBounds): AgentBrowserBounds {
|
||||
const normalized = {
|
||||
x: Math.trunc(bounds.x),
|
||||
|
||||
@@ -15,6 +15,7 @@ import type {
|
||||
export type WorksSubmissionBindingStore = ReturnType<typeof createWorksSubmissionBindingStore>;
|
||||
|
||||
export interface AgentBrowserService {
|
||||
preflightCurrentProject(projectPath: string): Promise<{ ok: true }>;
|
||||
getSnapshot(projectPath?: string): Promise<AgentBrowserSnapshot> | AgentBrowserSnapshot;
|
||||
open(input: {
|
||||
projectId: string;
|
||||
|
||||
@@ -125,6 +125,30 @@ function sendPublishSourceFailure(
|
||||
sendJson(res, 200, { success: false, status, code, error });
|
||||
}
|
||||
|
||||
const SAFE_PUBLISH_PREFLIGHT_CODES = new Set([
|
||||
'PREVIEW_REQUIRED',
|
||||
'PUBLISH_PREFLIGHT_LOAD_FAILED',
|
||||
'PUBLISH_PREFLIGHT_RUNTIME_ERROR',
|
||||
'PUBLISH_PREFLIGHT_BLANK',
|
||||
'PUBLISH_PREFLIGHT_TIMEOUT',
|
||||
]);
|
||||
const SAFE_PUBLISH_PREFLIGHT_MESSAGES: Record<string, string> = {
|
||||
PREVIEW_REQUIRED: '请先在 Makelore 内置浏览器中打开当前项目预览。',
|
||||
PUBLISH_PREFLIGHT_LOAD_FAILED: '作品主页无法打开。',
|
||||
PUBLISH_PREFLIGHT_RUNTIME_ERROR: '作品打开时发生了运行错误。',
|
||||
PUBLISH_PREFLIGHT_BLANK: '作品打开后没有可见内容。',
|
||||
PUBLISH_PREFLIGHT_TIMEOUT: '作品打开检查超时。',
|
||||
};
|
||||
|
||||
function isSafePublishPreflightError(
|
||||
error: unknown,
|
||||
): error is Error & { code: string } {
|
||||
return error instanceof Error
|
||||
&& 'code' in error
|
||||
&& typeof (error as { code?: unknown }).code === 'string'
|
||||
&& SAFE_PUBLISH_PREFLIGHT_CODES.has((error as { code: string }).code);
|
||||
}
|
||||
|
||||
async function sendPublishSourceUpstreamError(
|
||||
res: ServerResponse,
|
||||
response: Response,
|
||||
@@ -826,6 +850,16 @@ async function handlePublishProjectSource(
|
||||
const temporaryDirectory = await mkdtemp(join(tmpdir(), 'makelore-publish-'));
|
||||
const archivePath = join(temporaryDirectory, 'project.zip');
|
||||
try {
|
||||
if (!ctx.agentBrowser) {
|
||||
sendPublishSourceFailure(
|
||||
res,
|
||||
400,
|
||||
'PREVIEW_REQUIRED',
|
||||
'请先在 Makelore 内置浏览器中打开当前项目预览。',
|
||||
);
|
||||
return;
|
||||
}
|
||||
await ctx.agentBrowser.preflightCurrentProject(localProject.path);
|
||||
const packageSummary = await createStaticProjectPackage({
|
||||
projectPath: localProject.path,
|
||||
archivePath,
|
||||
@@ -1115,9 +1149,13 @@ export async function handleWorksRoutes(
|
||||
const isPackageError = error instanceof ProjectPackageError;
|
||||
sendPublishSourceFailure(
|
||||
res,
|
||||
isPackageError ? 400 : 503,
|
||||
isPackageError ? error.code : 'WORKS_SQUARE_UNAVAILABLE',
|
||||
isPackageError ? error.message : '发布服务暂时不可用,请稍后重试。',
|
||||
isPackageError || isSafePublishPreflightError(error) ? 400 : 503,
|
||||
isPackageError || isSafePublishPreflightError(error) ? error.code : 'WORKS_SQUARE_UNAVAILABLE',
|
||||
isPackageError
|
||||
? error.message
|
||||
: isSafePublishPreflightError(error)
|
||||
? SAFE_PUBLISH_PREFLIGHT_MESSAGES[error.code]
|
||||
: '发布服务暂时不可用,请稍后重试。',
|
||||
);
|
||||
} else if (isProjectStatus) {
|
||||
sendPublishSourceFailure(
|
||||
|
||||
@@ -753,3 +753,26 @@ if (gotTheLock) {
|
||||
|
||||
// Export for testing
|
||||
export { mainWindow, opencodeManager, opencodeProjectStore };
|
||||
|
||||
export async function runLocalPreviewPreflightE2E(url: string): Promise<{ ok: true }> {
|
||||
if (!isE2EMode || !agentBrowser) throw new Error('E2E local preview preflight is unavailable');
|
||||
const projectPath = join(app.getPath('temp'), 'makelore-local-preview-preflight-e2e');
|
||||
await agentBrowser.open({
|
||||
projectId: 'local-preview-preflight-e2e',
|
||||
projectPath,
|
||||
url,
|
||||
visible: false,
|
||||
});
|
||||
try {
|
||||
return await agentBrowser.preflightCurrentProject(projectPath);
|
||||
} finally {
|
||||
await agentBrowser.close(projectPath).catch(() => undefined);
|
||||
await agentBrowser.resetProfile(projectPath).catch(() => undefined);
|
||||
}
|
||||
}
|
||||
|
||||
if (isE2EMode) {
|
||||
(globalThis as typeof globalThis & {
|
||||
__niancodeRunLocalPreviewPreflightE2E?: typeof runLocalPreviewPreflightE2E;
|
||||
}).__niancodeRunLocalPreviewPreflightE2E = runLocalPreviewPreflightE2E;
|
||||
}
|
||||
|
||||
@@ -183,7 +183,7 @@ export function ProjectPublishAction({ project, projectType }: ProjectPublishAct
|
||||
{phase === 'submitting'
|
||||
? 'Makelore 正在自动检查、打包并提交项目,不需要准备 ZIP。'
|
||||
: phase === 'polling'
|
||||
? '项目已提交,正在等待云端构建与自动浏览器检查。'
|
||||
? '本地预览检查已完成,项目已上传,正在等待云端受控构建与平台校验。'
|
||||
: '已提交,等待运营审核。审核通过后会直接发布。'}
|
||||
</p>
|
||||
) : null}
|
||||
|
||||
@@ -7,6 +7,36 @@ export type WorksPublishFailure = {
|
||||
};
|
||||
|
||||
const PUBLISH_FAILURES: Record<string, WorksPublishFailure> = {
|
||||
PREVIEW_REQUIRED: {
|
||||
title: '请先打开项目预览',
|
||||
reason: '提交前检查需要当前项目正在 Makelore 内置浏览器中运行。',
|
||||
nextStep: '先在内置浏览器中打开当前项目的本地预览,确认页面可用后再提交。',
|
||||
},
|
||||
PUBLISH_PREFLIGHT_LOAD_FAILED: {
|
||||
title: '作品主页无法打开',
|
||||
reason: 'Makelore 使用内置浏览器检查时,主页面没有成功加载。',
|
||||
nextStep: '请让开发助手检查构建输出中的 index.html 和资源路径。',
|
||||
},
|
||||
PUBLISH_PREFLIGHT_RUNTIME_ERROR: {
|
||||
title: '作品打开时发生错误',
|
||||
reason: 'Makelore 的内置浏览器检测到页面运行错误。',
|
||||
nextStep: '请让开发助手用项目预览修复运行错误后重新提交。',
|
||||
},
|
||||
PUBLISH_PREFLIGHT_BLANK: {
|
||||
title: '作品打开后没有内容',
|
||||
reason: '桌面或移动视口下没有检测到可见页面内容。',
|
||||
nextStep: '请让开发助手检查首屏渲染、页面尺寸和移动端布局。',
|
||||
},
|
||||
PUBLISH_PREFLIGHT_TIMEOUT: {
|
||||
title: '作品打开检查超时',
|
||||
reason: '内置浏览器没有在规定时间内完成页面检查。',
|
||||
nextStep: '请让开发助手检查首屏资源大小和启动逻辑。',
|
||||
},
|
||||
PUBLISH_PREFLIGHT_UNAVAILABLE: {
|
||||
title: '暂时无法完成本地浏览器检查',
|
||||
reason: 'Makelore 的内置浏览器当前不可用。',
|
||||
nextStep: '重新启动 Makelore 后再提交,无需安装额外浏览器。',
|
||||
},
|
||||
AUTH_REQUIRED: {
|
||||
title: '需要重新登录',
|
||||
reason: '当前登录状态已失效,无法提交作品。',
|
||||
|
||||
57
tests/e2e/local-preview-preflight.spec.ts
Normal file
57
tests/e2e/local-preview-preflight.spec.ts
Normal file
@@ -0,0 +1,57 @@
|
||||
import { createServer } from 'node:http';
|
||||
import { test, expect } from './fixtures/electron';
|
||||
|
||||
test('the production local preview preflight isolates requests and cleans up its temporary renderers', async ({ electronApp }) => {
|
||||
let externalRequests = 0;
|
||||
let externalPageLoads = 0;
|
||||
const externalServer = createServer((_request, response) => {
|
||||
externalRequests += 1;
|
||||
response.writeHead(200, { 'Content-Type': 'application/javascript' });
|
||||
response.end('globalThis.externalLoaded = true;');
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
externalServer.once('error', reject);
|
||||
externalServer.listen(0, '127.0.0.1', () => resolve());
|
||||
});
|
||||
const externalAddress = externalServer.address();
|
||||
if (!externalAddress || typeof externalAddress === 'string') throw new Error('Expected an ephemeral TCP port');
|
||||
|
||||
const server = createServer((request, response) => {
|
||||
response.writeHead(200, { 'Content-Type': 'text/html; charset=utf-8' });
|
||||
if (request.url === '/external') externalPageLoads += 1;
|
||||
const externalScript = request.url === '/external' && externalPageLoads > 1
|
||||
? `<script src="http://127.0.0.1:${externalAddress.port}/blocked.js"></script>`
|
||||
: '';
|
||||
response.end(`<!doctype html><meta name="viewport" content="width=device-width,initial-scale=1"><main style="width:100vw;height:100vh">Playable</main>${externalScript}`);
|
||||
});
|
||||
await new Promise<void>((resolve, reject) => {
|
||||
server.once('error', reject);
|
||||
server.listen(0, '127.0.0.1', () => resolve());
|
||||
});
|
||||
const address = server.address();
|
||||
if (!address || typeof address === 'string') throw new Error('Expected an ephemeral TCP port');
|
||||
|
||||
try {
|
||||
const initialWebContentsCount = await electronApp.evaluate(({ webContents }) => webContents.getAllWebContents().length);
|
||||
const runPreflight = async (url: string) => await electronApp.evaluate(async (_electron, targetUrl) => {
|
||||
const mainGlobal = globalThis as typeof globalThis & {
|
||||
__niancodeRunLocalPreviewPreflightE2E?: (value: string) => Promise<{ ok: true }>;
|
||||
};
|
||||
if (!mainGlobal.__niancodeRunLocalPreviewPreflightE2E) throw new Error('E2E preflight seam is unavailable');
|
||||
return await mainGlobal.__niancodeRunLocalPreviewPreflightE2E(targetUrl);
|
||||
}, url);
|
||||
|
||||
await expect(runPreflight(`http://127.0.0.1:${address.port}/external`)).rejects.toThrow();
|
||||
expect(externalRequests).toBe(0);
|
||||
|
||||
const result = await runPreflight(`http://127.0.0.1:${address.port}/`);
|
||||
|
||||
expect(result).toEqual({ ok: true });
|
||||
await expect.poll(
|
||||
async () => await electronApp.evaluate(({ webContents }) => webContents.getAllWebContents().length),
|
||||
).toBe(initialWebContentsCount);
|
||||
} finally {
|
||||
await new Promise<void>((resolve) => server.close(() => resolve()));
|
||||
await new Promise<void>((resolve) => externalServer.close(() => resolve()));
|
||||
}
|
||||
});
|
||||
@@ -116,6 +116,7 @@ class FakeWebContents implements AgentBrowserWebContentsPort {
|
||||
windowOpenDenied = false;
|
||||
onLoad?: () => void;
|
||||
loadHandler?: (url: string) => Promise<void>;
|
||||
evaluateHandler?: (code: string) => Promise<unknown>;
|
||||
|
||||
async loadURL(url: string): Promise<void> {
|
||||
this.debugger.operationLog.push(`load:${url}`);
|
||||
@@ -145,6 +146,17 @@ class FakeWebContents implements AgentBrowserWebContentsPort {
|
||||
this.reloadCalls += 1;
|
||||
}
|
||||
|
||||
async executeJavaScript(code: string): Promise<unknown> {
|
||||
const overridden = this.debugger.commands.findLast(
|
||||
(command) => command.method === 'Emulation.setDeviceMetricsOverride',
|
||||
)?.params;
|
||||
return await (this.evaluateHandler?.(code) ?? Promise.resolve({
|
||||
readyState: 'complete',
|
||||
visible: true,
|
||||
viewport: { width: overridden?.width, height: overridden?.height },
|
||||
}));
|
||||
}
|
||||
|
||||
denyWindowOpen(): void {
|
||||
this.windowOpenDenied = true;
|
||||
}
|
||||
@@ -185,6 +197,8 @@ class FakeAdapter implements AgentBrowserAdapter {
|
||||
destroyed = 0;
|
||||
onCreate?: (view: FakeView) => void;
|
||||
resetHandler?: (partition: string) => Promise<void>;
|
||||
destroyHandler?: () => void;
|
||||
readonly restrictedOrigins: Array<{ partition: string; origin: string; released: boolean }> = [];
|
||||
|
||||
createView(partition: string): AgentBrowserViewPort {
|
||||
const view = new FakeView();
|
||||
@@ -205,12 +219,21 @@ class FakeAdapter implements AgentBrowserAdapter {
|
||||
destroy(view: AgentBrowserViewPort): void {
|
||||
this.destroyed += 1;
|
||||
(view.webContents as FakeWebContents).destroyed = true;
|
||||
this.destroyHandler?.();
|
||||
}
|
||||
|
||||
async resetPartition(partition: string): Promise<void> {
|
||||
this.resetPartitions.push(partition);
|
||||
await this.resetHandler?.(partition);
|
||||
}
|
||||
|
||||
restrictPartitionToOrigin(partition: string, origin: string): () => void {
|
||||
const record = { partition, origin, released: false };
|
||||
this.restrictedOrigins.push(record);
|
||||
return () => {
|
||||
record.released = true;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const projectPath = 'D:\\student\\clock';
|
||||
@@ -229,6 +252,180 @@ async function openBrowser(adapter = new FakeAdapter()) {
|
||||
}
|
||||
|
||||
describe('AgentBrowserModule', () => {
|
||||
it('preflights desktop and mobile viewports in temporary non-persistent profiles', async () => {
|
||||
const adapter = new FakeAdapter();
|
||||
adapter.onCreate = (view) => {
|
||||
view.webContents.evaluateHandler = async () => {
|
||||
const metrics = view.webContents.debugger.commands.findLast(
|
||||
(command) => command.method === 'Emulation.setDeviceMetricsOverride',
|
||||
)?.params;
|
||||
return {
|
||||
readyState: 'complete',
|
||||
visible: true,
|
||||
viewport: { width: metrics?.width, height: metrics?.height },
|
||||
};
|
||||
};
|
||||
};
|
||||
const module = new AgentBrowserModule(adapter);
|
||||
|
||||
await module.open({ projectId: 'clock', projectPath, url: 'http://127.0.0.1:4173/' });
|
||||
await expect(module.preflightCurrentProject(projectPath)).resolves.toEqual({ ok: true });
|
||||
|
||||
expect(adapter.partitions).toHaveLength(3);
|
||||
expect(adapter.partitions.slice(1).every((partition) => partition.startsWith('niancode-publish-preflight:'))).toBe(true);
|
||||
expect(adapter.partitions.slice(1).every((partition) => !partition.startsWith('persist:'))).toBe(true);
|
||||
expect(adapter.views.slice(1).map((view) => view.bounds)).toEqual([
|
||||
{ x: 0, y: 0, width: 1280, height: 720 },
|
||||
{ x: 0, y: 0, width: 390, height: 844 },
|
||||
]);
|
||||
expect(adapter.mounted).toBe(1);
|
||||
expect(adapter.destroyed).toBe(2);
|
||||
expect(adapter.resetPartitions).toEqual(adapter.partitions.slice(1));
|
||||
expect(adapter.restrictedOrigins).toEqual(adapter.partitions.slice(1).map((partition) => ({
|
||||
partition,
|
||||
origin: 'http://127.0.0.1:4173',
|
||||
released: true,
|
||||
})));
|
||||
});
|
||||
|
||||
it.each([
|
||||
['console error', (view: FakeView) => view.webContents.debugger.message('Runtime.consoleAPICalled', { type: 'error' })],
|
||||
['page exception', (view: FakeView) => view.webContents.debugger.message('Runtime.exceptionThrown', { exceptionDetails: {} })],
|
||||
['log error', (view: FakeView) => view.webContents.debugger.message('Log.entryAdded', { entry: { level: 'error' } })],
|
||||
['failed response', (view: FakeView) => view.webContents.debugger.message('Network.responseReceived', { response: { status: 404 } })],
|
||||
['external request', (view: FakeView) => view.webContents.debugger.message('Network.requestWillBeSent', { request: { url: 'https://evil.example/track' } })],
|
||||
])('fails and cleans up a publish preflight on %s', async (_case, trigger) => {
|
||||
const adapter = new FakeAdapter();
|
||||
adapter.onCreate = (view) => {
|
||||
view.webContents.evaluateHandler = async () => {
|
||||
trigger(view);
|
||||
const metrics = view.webContents.debugger.commands.findLast(
|
||||
(command) => command.method === 'Emulation.setDeviceMetricsOverride',
|
||||
)?.params;
|
||||
return { readyState: 'complete', visible: true, viewport: { width: metrics?.width, height: metrics?.height } };
|
||||
};
|
||||
};
|
||||
const module = new AgentBrowserModule(adapter);
|
||||
|
||||
await module.open({ projectId: 'clock', projectPath, url: 'http://127.0.0.1:4173/' });
|
||||
await expect(module.preflightCurrentProject(projectPath)).rejects.toMatchObject({
|
||||
code: 'PUBLISH_PREFLIGHT_RUNTIME_ERROR',
|
||||
});
|
||||
expect(adapter.destroyed).toBe(1);
|
||||
expect(adapter.resetPartitions).toEqual(adapter.partitions.slice(1));
|
||||
});
|
||||
|
||||
it('ignores a cancelled navigation resource instead of reporting a runtime error', async () => {
|
||||
const adapter = new FakeAdapter();
|
||||
adapter.onCreate = (view) => {
|
||||
if (adapter.views.length === 1) return;
|
||||
view.webContents.evaluateHandler = async () => {
|
||||
view.webContents.debugger.message('Network.loadingFailed', {
|
||||
canceled: true,
|
||||
errorText: 'net::ERR_ABORTED',
|
||||
});
|
||||
const metrics = view.webContents.debugger.commands.findLast(
|
||||
(command) => command.method === 'Emulation.setDeviceMetricsOverride',
|
||||
)?.params;
|
||||
return { readyState: 'complete', visible: true, viewport: { width: metrics?.width, height: metrics?.height } };
|
||||
};
|
||||
};
|
||||
const module = new AgentBrowserModule(adapter);
|
||||
await module.open({ projectId: 'clock', projectPath, url: 'http://127.0.0.1:4173/' });
|
||||
|
||||
await expect(module.preflightCurrentProject(projectPath)).resolves.toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('rejects a top-level redirect to another origin', async () => {
|
||||
const adapter = new FakeAdapter();
|
||||
adapter.onCreate = (view) => {
|
||||
if (adapter.views.length === 1) return;
|
||||
view.webContents.evaluateHandler = async () => {
|
||||
view.webContents.url = 'http://127.0.0.1:4174/redirected';
|
||||
return { readyState: 'complete', visible: true };
|
||||
};
|
||||
};
|
||||
const module = new AgentBrowserModule(adapter);
|
||||
await module.open({ projectId: 'clock', projectPath, url: 'http://127.0.0.1:4173/' });
|
||||
|
||||
await expect(module.preflightCurrentProject(projectPath)).rejects.toMatchObject({
|
||||
code: 'PUBLISH_PREFLIGHT_LOAD_FAILED',
|
||||
});
|
||||
});
|
||||
|
||||
it('rejects a white screen without exposing page content and still cleans up', async () => {
|
||||
const adapter = new FakeAdapter();
|
||||
adapter.onCreate = (view) => {
|
||||
view.webContents.evaluateHandler = async () => ({
|
||||
readyState: 'complete',
|
||||
visible: false,
|
||||
privateText: 'C:\\private\\student-project',
|
||||
});
|
||||
};
|
||||
const module = new AgentBrowserModule(adapter);
|
||||
|
||||
await module.open({ projectId: 'clock', projectPath, url: 'http://127.0.0.1:4173/' });
|
||||
await expect(module.preflightCurrentProject(projectPath)).rejects.toMatchObject({
|
||||
code: 'PUBLISH_PREFLIGHT_BLANK',
|
||||
message: '作品打开后没有可见内容。',
|
||||
});
|
||||
expect(adapter.destroyed).toBe(1);
|
||||
expect(adapter.resetPartitions).toEqual(adapter.partitions.slice(1));
|
||||
});
|
||||
|
||||
it('continues cleanup when destroy and origin-release fail', async () => {
|
||||
const adapter = new FakeAdapter();
|
||||
adapter.destroyHandler = () => {
|
||||
throw new Error('destroy failed');
|
||||
};
|
||||
adapter.onCreate = (view) => {
|
||||
view.webContents.evaluateHandler = async () => ({ readyState: 'complete', visible: false });
|
||||
};
|
||||
const originalRestrict = adapter.restrictPartitionToOrigin.bind(adapter);
|
||||
adapter.restrictPartitionToOrigin = (partition, origin) => {
|
||||
originalRestrict(partition, origin);
|
||||
return () => {
|
||||
throw new Error('release failed');
|
||||
};
|
||||
};
|
||||
const module = new AgentBrowserModule(adapter);
|
||||
await module.open({ projectId: 'clock', projectPath, url: 'http://127.0.0.1:4173/' });
|
||||
|
||||
await expect(module.preflightCurrentProject(projectPath)).rejects.toMatchObject({
|
||||
code: 'PUBLISH_PREFLIGHT_BLANK',
|
||||
});
|
||||
expect(adapter.destroyed).toBe(1);
|
||||
expect(adapter.resetPartitions).toEqual(adapter.partitions.slice(1));
|
||||
});
|
||||
|
||||
it.each([
|
||||
'https://example.com:443/',
|
||||
'https://localhost:4173/',
|
||||
'http://localhost/',
|
||||
'http://user:secret@127.0.0.1:4173/',
|
||||
])('requires an explicit credential-free loopback preview URL: %s', async (url) => {
|
||||
const adapter = new FakeAdapter();
|
||||
const module = new AgentBrowserModule(adapter);
|
||||
await module.open({ projectId: 'clock', projectPath, url });
|
||||
|
||||
await expect(module.preflightCurrentProject(projectPath)).rejects.toMatchObject({
|
||||
code: 'PREVIEW_REQUIRED',
|
||||
});
|
||||
expect(adapter.views).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('requires the current project preview and leaves the user browser record untouched', async () => {
|
||||
const { adapter, module, snapshot, view } = await openBrowser();
|
||||
const before = await module.getSnapshot(projectPath);
|
||||
|
||||
await expect(module.preflightCurrentProject('D:\\student\\other')).rejects.toMatchObject({
|
||||
code: 'PREVIEW_REQUIRED',
|
||||
});
|
||||
expect(await module.getSnapshot(projectPath)).toEqual(before);
|
||||
expect(view.webContents.getURL()).toBe(snapshot.url);
|
||||
expect(adapter.views).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('primes the renderer before attaching CDP and loading the shared page', async () => {
|
||||
const adapter = new FakeAdapter();
|
||||
adapter.onCreate = (view) => {
|
||||
|
||||
@@ -40,6 +40,7 @@ const electronMocks = vi.hoisted(() => {
|
||||
setPermissionCheckHandler: permissionCheckHandler,
|
||||
clearStorageData: vi.fn().mockResolvedValue(undefined),
|
||||
clearCache: vi.fn().mockResolvedValue(undefined),
|
||||
webRequest: { onBeforeRequest: vi.fn() },
|
||||
});
|
||||
|
||||
class MockWebContentsView {
|
||||
@@ -64,6 +65,7 @@ const electronMocks = vi.hoisted(() => {
|
||||
isDestroyed: vi.fn().mockReturnValue(false),
|
||||
isDevToolsOpened: vi.fn().mockReturnValue(false),
|
||||
reload: vi.fn(),
|
||||
executeJavaScript: vi.fn().mockResolvedValue({ ok: true }),
|
||||
close: vi.fn(),
|
||||
setWindowOpenHandler: vi.fn(),
|
||||
});
|
||||
@@ -100,7 +102,7 @@ describe('ElectronAgentBrowserAdapter', () => {
|
||||
electronMocks.browserSession.removeAllListeners();
|
||||
});
|
||||
|
||||
it('creates an isolated sandboxed WebContentsView and mounts it in Main', () => {
|
||||
it('creates an isolated sandboxed WebContentsView and mounts it in Main', async () => {
|
||||
const addChildView = vi.fn();
|
||||
const removeChildView = vi.fn();
|
||||
const mainWindow = {
|
||||
@@ -139,6 +141,7 @@ describe('ElectronAgentBrowserAdapter', () => {
|
||||
view.setBounds({ x: 100, y: 80, width: 800, height: 600 });
|
||||
view.setVisible(true);
|
||||
view.webContents.navigationHistory.clear();
|
||||
await expect(view.webContents.executeJavaScript('document.readyState')).resolves.toEqual({ ok: true });
|
||||
expect(electronMocks.nativeViews[0].setBounds).toHaveBeenCalledWith({
|
||||
x: 125,
|
||||
y: 100,
|
||||
@@ -208,4 +211,34 @@ describe('ElectronAgentBrowserAdapter', () => {
|
||||
expect(customRedirect.preventDefault).toHaveBeenCalledOnce();
|
||||
expect(webNavigation.preventDefault).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('cancels cross-origin web requests before they leave a temporary partition', () => {
|
||||
const adapter = new ElectronAgentBrowserAdapter({
|
||||
isDestroyed: vi.fn().mockReturnValue(false),
|
||||
contentView: { addChildView: vi.fn(), removeChildView: vi.fn() },
|
||||
webContents: { getZoomFactor: vi.fn().mockReturnValue(1) },
|
||||
getContentBounds: vi.fn().mockReturnValue({ x: 0, y: 0, width: 1024, height: 768 }),
|
||||
} as never);
|
||||
const release = adapter.restrictPartitionToOrigin(
|
||||
'niancode-publish-preflight:test',
|
||||
'http://127.0.0.1:4173',
|
||||
);
|
||||
const listener = electronMocks.browserSession.webRequest.onBeforeRequest.mock.calls[0]?.[1];
|
||||
const sameOrigin = vi.fn();
|
||||
const external = vi.fn();
|
||||
const websocket = vi.fn();
|
||||
const secureWebsocket = vi.fn();
|
||||
|
||||
listener({ url: 'http://127.0.0.1:4173/app.js' }, sameOrigin);
|
||||
listener({ url: 'https://evil.example/track' }, external);
|
||||
listener({ url: 'ws://127.0.0.1:4173/socket' }, websocket);
|
||||
listener({ url: 'wss://127.0.0.1:4173/socket' }, secureWebsocket);
|
||||
release();
|
||||
|
||||
expect(sameOrigin).toHaveBeenCalledWith({ cancel: false });
|
||||
expect(external).toHaveBeenCalledWith({ cancel: true });
|
||||
expect(websocket).toHaveBeenCalledWith({ cancel: true });
|
||||
expect(secureWebsocket).toHaveBeenCalledWith({ cancel: true });
|
||||
expect(electronMocks.browserSession.webRequest.onBeforeRequest).toHaveBeenLastCalledWith(null);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -82,6 +82,10 @@ describe('ProjectPublishAction', () => {
|
||||
expect(screen.getByRole('button', { name: '正在检查并提交…' })).toBeDisabled();
|
||||
await flushSubmission();
|
||||
|
||||
expect(screen.getByTestId('project-publish-status')).toHaveTextContent(
|
||||
'本地预览检查已完成,项目已上传,正在等待云端受控构建与平台校验。',
|
||||
);
|
||||
|
||||
expect(publishWorksProjectSourceMock).toHaveBeenCalledWith({
|
||||
projectId: 'prj_space_cleaner',
|
||||
project: {
|
||||
|
||||
@@ -6,6 +6,9 @@ import {
|
||||
|
||||
describe('works project publish guidance', () => {
|
||||
it.each([
|
||||
['PREVIEW_REQUIRED', '请先打开项目预览', '内置浏览器'],
|
||||
['PUBLISH_PREFLIGHT_RUNTIME_ERROR', '作品打开时发生错误', '项目预览'],
|
||||
['PUBLISH_PREFLIGHT_BLANK', '作品打开后没有内容', '移动端布局'],
|
||||
['PROJECT_FILE_MISSING', '项目文件不完整', '修复当前项目模板'],
|
||||
['PROJECT_TYPE_UNPUBLISHABLE', '这个项目没有配置发布方式', '新建小游戏或小程序项目'],
|
||||
['DEPENDENCY_PREFETCH_FAILED', '暂时无法下载项目依赖', 'package-lock.json'],
|
||||
|
||||
@@ -18,6 +18,7 @@ vi.mock('@electron/services/works-square-session', () => ({
|
||||
getValidWorksSquareAccessToken: (...args: unknown[]) => getValidWorksSquareAccessTokenMock(...args),
|
||||
}));
|
||||
|
||||
|
||||
function createResponse() {
|
||||
const chunks: string[] = [];
|
||||
const res = {
|
||||
@@ -1082,6 +1083,7 @@ describe('works square host api routes', () => {
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const response = createResponse();
|
||||
const recordSubmitted = vi.fn(async () => undefined);
|
||||
const preflightCurrentProject = vi.fn(async () => ({ ok: true as const }));
|
||||
|
||||
const handled = await handleWorksRoutes(
|
||||
createRendererRequest('POST', { projectId: project.id, project: projectMetadata }),
|
||||
@@ -1089,6 +1091,7 @@ describe('works square host api routes', () => {
|
||||
new URL('http://127.0.0.1/api/works/projects/publish-source'),
|
||||
{
|
||||
opencodeProjectStore: { listProjects: vi.fn(async () => [project]) },
|
||||
agentBrowser: { preflightCurrentProject },
|
||||
worksSubmissionBinding: { recordSubmitted },
|
||||
} as never,
|
||||
);
|
||||
@@ -1153,6 +1156,50 @@ describe('works square host api routes', () => {
|
||||
reviewStatus: 'building',
|
||||
zipSha256: expect.stringMatching(/^[a-f0-9]{64}$/),
|
||||
});
|
||||
expect(preflightCurrentProject).toHaveBeenCalledWith(tempDir);
|
||||
});
|
||||
|
||||
it('stops before creating or uploading when the local browser preflight fails', async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), 'makelore-source-preflight-failure-'));
|
||||
await writePublishableProject(tempDir);
|
||||
const preflightCurrentProject = vi.fn(async () => {
|
||||
throw Object.assign(new Error(`${tempDir} token=secret`), {
|
||||
code: 'PUBLISH_PREFLIGHT_BLANK',
|
||||
});
|
||||
});
|
||||
const fetchMock = vi.fn();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const response = createResponse();
|
||||
|
||||
await handleWorksRoutes(
|
||||
createRendererRequest('POST', {
|
||||
projectId: 'project-1',
|
||||
project: {
|
||||
app_id: 'space-cleaner',
|
||||
title: '太空清洁队',
|
||||
summary: '收集漂浮垃圾的小游戏。',
|
||||
},
|
||||
}),
|
||||
response.res,
|
||||
new URL('http://127.0.0.1/api/works/projects/publish-source'),
|
||||
{
|
||||
opencodeProjectStore: {
|
||||
listProjects: vi.fn(async () => [{ id: 'project-1', path: tempDir, name: 'space-cleaner' }]),
|
||||
},
|
||||
agentBrowser: { preflightCurrentProject },
|
||||
} as never,
|
||||
);
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
expect(response.json()).toEqual({
|
||||
success: false,
|
||||
status: 400,
|
||||
code: 'PUBLISH_PREFLIGHT_BLANK',
|
||||
error: '作品打开后没有可见内容。',
|
||||
});
|
||||
expect(fetchMock).not.toHaveBeenCalled();
|
||||
expect(JSON.stringify(response.json())).not.toContain(tempDir);
|
||||
expect(JSON.stringify(response.json())).not.toContain('token=secret');
|
||||
});
|
||||
|
||||
it('keeps a confirmed submission successful when the local preview mapping cannot be saved', async () => {
|
||||
@@ -1181,6 +1228,7 @@ describe('works square host api routes', () => {
|
||||
new URL('http://127.0.0.1/api/works/projects/publish-source'),
|
||||
{
|
||||
opencodeProjectStore: { listProjects: vi.fn(async () => [project]) },
|
||||
agentBrowser: { preflightCurrentProject: vi.fn(async () => ({ ok: true })) },
|
||||
worksSubmissionBinding: {
|
||||
recordSubmitted: vi.fn(async () => { throw new Error('disk unavailable'); }),
|
||||
},
|
||||
@@ -1225,7 +1273,10 @@ describe('works square host api routes', () => {
|
||||
}),
|
||||
response.res,
|
||||
new URL('http://127.0.0.1/api/works/projects/publish-source'),
|
||||
{ opencodeProjectStore: { listProjects: vi.fn(async () => [project]) } } as never,
|
||||
{
|
||||
opencodeProjectStore: { listProjects: vi.fn(async () => [project]) },
|
||||
agentBrowser: { preflightCurrentProject: vi.fn(async () => ({ ok: true })) },
|
||||
} as never,
|
||||
);
|
||||
|
||||
expect(response.json().success).toBe(true);
|
||||
@@ -1253,7 +1304,10 @@ describe('works square host api routes', () => {
|
||||
.mockRejectedValueOnce(new Error('socket reset with secret upstream detail'))
|
||||
.mockResolvedValueOnce(new Response(uploadPayload, { status: 201 }));
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const ctx = { opencodeProjectStore: { listProjects: vi.fn(async () => [project]) } } as never;
|
||||
const ctx = {
|
||||
opencodeProjectStore: { listProjects: vi.fn(async () => [project]) },
|
||||
agentBrowser: { preflightCurrentProject: vi.fn(async () => ({ ok: true })) },
|
||||
} as never;
|
||||
|
||||
for (let index = 0; index < 2; index += 1) {
|
||||
const response = createResponse();
|
||||
@@ -1307,7 +1361,10 @@ describe('works square host api routes', () => {
|
||||
}),
|
||||
response.res,
|
||||
new URL('http://127.0.0.1/api/works/projects/publish-source'),
|
||||
{ opencodeProjectStore: { listProjects: vi.fn(async () => [project]) } } as never,
|
||||
{
|
||||
opencodeProjectStore: { listProjects: vi.fn(async () => [project]) },
|
||||
agentBrowser: { preflightCurrentProject: vi.fn(async () => ({ ok: true })) },
|
||||
} as never,
|
||||
);
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
@@ -1343,7 +1400,10 @@ describe('works square host api routes', () => {
|
||||
}),
|
||||
response.res,
|
||||
new URL('http://127.0.0.1/api/works/projects/publish-source'),
|
||||
{ opencodeProjectStore: { listProjects: vi.fn(async () => [project]) } } as never,
|
||||
{
|
||||
opencodeProjectStore: { listProjects: vi.fn(async () => [project]) },
|
||||
agentBrowser: { preflightCurrentProject: vi.fn(async () => ({ ok: true })) },
|
||||
} as never,
|
||||
);
|
||||
|
||||
expect(response.statusCode).toBe(200);
|
||||
|
||||
Reference in New Issue
Block a user