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

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