feat: 将发布预检移入内置浏览器

This commit is contained in:
2026-08-12 16:33:50 +08:00
parent be9bc84474
commit 926056a59b
16 changed files with 840 additions and 9 deletions

View 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()));
}
});

View File

@@ -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) => {

View File

@@ -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);
});
});

View File

@@ -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: {

View File

@@ -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'],

View File

@@ -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);