feat: 将发布预检移入内置浏览器
This commit is contained in:
@@ -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) => {
|
||||
|
||||
Reference in New Issue
Block a user