diff --git a/.project-docs/30-worklog/tasks/20260812-client-browser-preflight-c8e2.md b/.project-docs/30-worklog/tasks/20260812-client-browser-preflight-c8e2.md new file mode 100644 index 0000000..64b8f09 --- /dev/null +++ b/.project-docs/30-worklog/tasks/20260812-client-browser-preflight-c8e2.md @@ -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 统一提升。 diff --git a/README.md b/README.md index 54e90a6..96c9009 100644 --- a/README.md +++ b/README.md @@ -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、下载依赖或执行项目构建脚本。没有活动的本地预览时会提示先在内置浏览器打开预览;该检查只改善提交前反馈,服务端仍独立重建、校验和发布,客户端不会上传“已通过”声明。 ### 真机预览 diff --git a/electron/agent-browser/adapter.ts b/electron/agent-browser/adapter.ts index d25f3c7..5d93672 100644 --- a/electron/agent-browser/adapter.ts +++ b/electron/agent-browser/adapter.ts @@ -32,6 +32,7 @@ export interface AgentBrowserWebContentsPort { isDestroyed(): boolean; isDevToolsOpened(): boolean; reload(): void; + executeJavaScript(code: string): Promise; 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; + restrictPartitionToOrigin(partition: string, origin: string): () => void; } diff --git a/electron/agent-browser/electron-adapter.ts b/electron/agent-browser/electron-adapter.ts index 90a563e..88ee99d 100644 --- a/electron/agent-browser/electron-adapter.ts +++ b/electron/agent-browser/electron-adapter.ts @@ -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.'); diff --git a/electron/agent-browser/module.ts b/electron/agent-browser/module.ts index 0d72fdb..a330fee 100644 --- a/electron/agent-browser/module.ts +++ b/electron/agent-browser/module.ts @@ -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 { 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 { + 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 { 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( + operation: Promise, + deadline: number, +): Promise { + const remaining = deadline - Date.now(); + if (remaining <= 0) throw new Error('PUBLISH_PREFLIGHT_TIMEOUT'); + let timer: ReturnType | undefined; + try { + return await Promise.race([ + operation, + new Promise((_resolve, reject) => { + timer = setTimeout(() => reject(new Error('PUBLISH_PREFLIGHT_TIMEOUT')), remaining); + }), + ]); + } finally { + if (timer) clearTimeout(timer); + } +} + +function delay(milliseconds: number): Promise { + 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 { + let timer: ReturnType | undefined; + try { + await Promise.race([ + adapter.resetPartition(partition).catch(() => undefined), + new Promise((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), diff --git a/electron/api/context.ts b/electron/api/context.ts index 06dbf5b..689fcae 100644 --- a/electron/api/context.ts +++ b/electron/api/context.ts @@ -15,6 +15,7 @@ import type { export type WorksSubmissionBindingStore = ReturnType; export interface AgentBrowserService { + preflightCurrentProject(projectPath: string): Promise<{ ok: true }>; getSnapshot(projectPath?: string): Promise | AgentBrowserSnapshot; open(input: { projectId: string; diff --git a/electron/api/routes/works.ts b/electron/api/routes/works.ts index 93bdd4f..dd43171 100644 --- a/electron/api/routes/works.ts +++ b/electron/api/routes/works.ts @@ -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 = { + 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( diff --git a/electron/main/index.ts b/electron/main/index.ts index 80b1eee..79199c9 100644 --- a/electron/main/index.ts +++ b/electron/main/index.ts @@ -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; +} diff --git a/src/components/works/ProjectPublishAction.tsx b/src/components/works/ProjectPublishAction.tsx index 0ed955d..003c769 100644 --- a/src/components/works/ProjectPublishAction.tsx +++ b/src/components/works/ProjectPublishAction.tsx @@ -183,7 +183,7 @@ export function ProjectPublishAction({ project, projectType }: ProjectPublishAct {phase === 'submitting' ? 'Makelore 正在自动检查、打包并提交项目,不需要准备 ZIP。' : phase === 'polling' - ? '项目已提交,正在等待云端构建与自动浏览器检查。' + ? '本地预览检查已完成,项目已上传,正在等待云端受控构建与平台校验。' : '已提交,等待运营审核。审核通过后会直接发布。'}

) : null} diff --git a/src/lib/works-project-publish.ts b/src/lib/works-project-publish.ts index 7183a65..7b08830 100644 --- a/src/lib/works-project-publish.ts +++ b/src/lib/works-project-publish.ts @@ -7,6 +7,36 @@ export type WorksPublishFailure = { }; const PUBLISH_FAILURES: Record = { + 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: '当前登录状态已失效,无法提交作品。', diff --git a/tests/e2e/local-preview-preflight.spec.ts b/tests/e2e/local-preview-preflight.spec.ts new file mode 100644 index 0000000..d832de0 --- /dev/null +++ b/tests/e2e/local-preview-preflight.spec.ts @@ -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((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 + ? `` + : ''; + response.end(`
Playable
${externalScript}`); + }); + await new Promise((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((resolve) => server.close(() => resolve())); + await new Promise((resolve) => externalServer.close(() => resolve())); + } +}); diff --git a/tests/unit/agent-browser-core.test.ts b/tests/unit/agent-browser-core.test.ts index 5cf52c7..56e325c 100644 --- a/tests/unit/agent-browser-core.test.ts +++ b/tests/unit/agent-browser-core.test.ts @@ -116,6 +116,7 @@ class FakeWebContents implements AgentBrowserWebContentsPort { windowOpenDenied = false; onLoad?: () => void; loadHandler?: (url: string) => Promise; + evaluateHandler?: (code: string) => Promise; async loadURL(url: string): Promise { this.debugger.operationLog.push(`load:${url}`); @@ -145,6 +146,17 @@ class FakeWebContents implements AgentBrowserWebContentsPort { this.reloadCalls += 1; } + async executeJavaScript(code: string): Promise { + 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; + 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 { 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) => { diff --git a/tests/unit/agent-browser-electron-adapter.test.ts b/tests/unit/agent-browser-electron-adapter.test.ts index fbcb098..f689aa8 100644 --- a/tests/unit/agent-browser-electron-adapter.test.ts +++ b/tests/unit/agent-browser-electron-adapter.test.ts @@ -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); + }); }); diff --git a/tests/unit/project-publish-action.test.tsx b/tests/unit/project-publish-action.test.tsx index 0e86133..4393189 100644 --- a/tests/unit/project-publish-action.test.tsx +++ b/tests/unit/project-publish-action.test.tsx @@ -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: { diff --git a/tests/unit/works-project-publish.test.ts b/tests/unit/works-project-publish.test.ts index ff71edb..b2d97de 100644 --- a/tests/unit/works-project-publish.test.ts +++ b/tests/unit/works-project-publish.test.ts @@ -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'], diff --git a/tests/unit/works-routes.test.ts b/tests/unit/works-routes.test.ts index 7c27ace..da8682f 100644 --- a/tests/unit/works-routes.test.ts +++ b/tests/unit/works-routes.test.ts @@ -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);