Skip to content

Commit 1d39b33

Browse files
midscenequanruottomao
authored
MID-5: support Web YAML downloadPath (#2688)
* feat(web-integration): support yaml download path * fix(web-integration): keep download behavior session active * test(web-integration): cover yaml download path * fix(web-integration): use puppeteer download behavior option --------- Co-authored-by: Multica Helper <midscene@users.noreply.github.com> Co-authored-by: quanru <quanruzhuoxiu@gmail.com> Co-authored-by: ottomao <ottomao@gmail.com>
1 parent 8d75a78 commit 1d39b33

12 files changed

Lines changed: 432 additions & 6 deletions

File tree

apps/site/docs/en/automate-with-scripts-in-yaml.mdx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,6 +166,11 @@ web:
166166
# Path to a JSON format browser cookie file, optional.
167167
cookie: <path-to-cookie-file>
168168

169+
# Chrome download directory (Puppeteer only), optional.
170+
# Relative paths are resolved from the current working directory.
171+
# Not supported in bridge mode.
172+
downloadPath: <path-to-download-directory>
173+
169174
# Extra HTTP headers sent with every request (Puppeteer only), optional.
170175
# Useful when the server validates custom request headers.
171176
# Values must be strings; quote values YAML would treat as a boolean or number, e.g. "true".

apps/site/docs/en/bridge-mode.mdx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,9 @@ In bridge mode, these options will be ignored (they will follow your desktop bro
110110
- deviceScaleFactor
111111
- waitForNetworkIdle
112112
- cookie
113+
- extraHTTPHeaders
114+
- downloadPath
115+
- chromeArgs
113116

114117
## Remote Access Configuration
115118

apps/site/docs/zh/automate-with-scripts-in-yaml.mdx

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -165,6 +165,11 @@ web:
165165
# JSON 格式的浏览器 Cookie 文件路径,可选
166166
cookie: <path-to-cookie-file>
167167

168+
# Chrome 下载目录(仅 Puppeteer 模式),可选
169+
# 相对路径会基于当前工作目录解析
170+
# bridgeMode 不支持该选项
171+
downloadPath: <path-to-download-directory>
172+
168173
# 随每个请求发送的额外 HTTP 请求头(仅 Puppeteer 模式),可选
169174
# 适用于服务器会校验自定义请求头的场景
170175
# 值必须是字符串;像 true、false、纯数字这类会被 YAML 解析为非字符串的值需加引号,如 "true"

apps/site/docs/zh/bridge-mode.mdx

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -102,6 +102,9 @@ midscene ./bing.yaml
102102
- `deviceScaleFactor`
103103
- `waitForNetworkIdle`
104104
- `cookie`
105+
- `extraHTTPHeaders`
106+
- `downloadPath`
107+
- `chromeArgs`
105108

106109
## 远程访问配置
107110

packages/cli/src/create-yaml-player.ts

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,10 @@ import type { AbstractInterface } from '@midscene/core/device';
1717
import { processCacheConfig } from '@midscene/core/utils';
1818
import { getDebug } from '@midscene/shared/logger';
1919
import { AgentOverChromeBridge } from '@midscene/web/bridge-mode';
20-
import { puppeteerAgentForTarget } from '@midscene/web/puppeteer-agent-launcher';
20+
import {
21+
buildDownloadBehavior,
22+
puppeteerAgentForTarget,
23+
} from '@midscene/web/puppeteer-agent-launcher';
2124
import type { Browser, Page } from 'puppeteer';
2225
import puppeteer from 'puppeteer';
2326

@@ -187,6 +190,7 @@ export async function createYamlPlayer(
187190
(await puppeteer.connect({
188191
browserWSEndpoint: webTarget.cdpEndpoint,
189192
defaultViewport: null,
193+
downloadBehavior: buildDownloadBehavior(webTarget.downloadPath),
190194
}));
191195

192196
// Warn about options that don't apply to an already-running browser
@@ -261,6 +265,7 @@ export async function createYamlPlayer(
261265
'waitForNetworkIdle',
262266
'cookie',
263267
'extraHTTPHeaders',
268+
'downloadPath',
264269
'chromeArgs',
265270
];
266271
const ignoredKeys = bridgeUnsupportedKeys.filter(

packages/cli/src/yaml-batch-executor.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import type {
1010
import { type ScriptPlayer, parseYamlScript } from '@midscene/core/yaml';
1111
import {
1212
buildChromeArgs,
13+
buildDownloadBehavior,
1314
defaultViewportHeight,
1415
defaultViewportWidth,
1516
} from '@midscene/web/puppeteer-agent-launcher';
@@ -127,6 +128,9 @@ class YamlBatchExecutor {
127128
browser = await puppeteer.connect({
128129
browserWSEndpoint: globalWebConfig.cdpEndpoint,
129130
defaultViewport: null,
131+
downloadBehavior: buildDownloadBehavior(
132+
globalWebConfig.downloadPath,
133+
),
130134
});
131135
} else {
132136
// Extract viewport dimensions from global config or use defaults
@@ -145,6 +149,9 @@ class YamlBatchExecutor {
145149
browser = await puppeteer.launch({
146150
headless: !headed,
147151
defaultViewport: headed ? null : { width, height },
152+
downloadBehavior: buildDownloadBehavior(
153+
globalWebConfig?.downloadPath,
154+
),
148155
args,
149156
acceptInsecureCerts: globalWebConfig?.acceptInsecureCerts,
150157
});

packages/cli/tests/unit-test/batch-runner.test.ts

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ import {
66
statSync,
77
writeFileSync,
88
} from 'node:fs';
9+
import path from 'node:path';
910
import { BatchRunner } from '@/batch-runner';
1011
import { createYamlPlayer } from '@/create-yaml-player';
1112
import type {
@@ -61,7 +62,18 @@ vi.mock('@midscene/web/puppeteer-agent-launcher', async (importOriginal) => {
6162
await importOriginal<
6263
typeof import('@midscene/web/puppeteer-agent-launcher')
6364
>();
64-
return { ...original };
65+
return {
66+
...original,
67+
buildDownloadBehavior: (downloadPath: string | undefined) =>
68+
downloadPath
69+
? {
70+
policy: 'allow',
71+
downloadPath: downloadPath.startsWith('/')
72+
? downloadPath
73+
: `${process.cwd()}/${downloadPath.replace(/^\.\//, '')}`,
74+
}
75+
: undefined,
76+
};
6577
});
6678
vi.mock('@midscene/web/bridge-mode');
6779
vi.mock('@midscene/android');
@@ -213,6 +225,30 @@ describe('BatchRunner', () => {
213225
expect(launchCall).toHaveProperty('acceptInsecureCerts', true);
214226
});
215227

228+
test('should pass downloadPath to Puppeteer launch options when shareBrowserContext is true', async () => {
229+
const config = {
230+
...mockBatchConfig,
231+
shareBrowserContext: true,
232+
files: ['web1.yml'],
233+
globalConfig: {
234+
web: {
235+
url: 'http://example.com',
236+
downloadPath: './downloads',
237+
},
238+
},
239+
};
240+
const runner = new BatchRunner(config);
241+
await runner.run();
242+
243+
expect(puppeteer.launch).toHaveBeenCalledTimes(1);
244+
245+
const launchCall = vi.mocked(puppeteer.launch).mock.calls[0][0];
246+
expect(launchCall).toHaveProperty('downloadBehavior', {
247+
policy: 'allow',
248+
downloadPath: path.resolve('./downloads'),
249+
});
250+
});
251+
216252
test('should not create a shared browser instance when shareBrowserContext is false', async () => {
217253
const config = {
218254
...mockBatchConfig,
@@ -273,11 +309,38 @@ describe('BatchRunner', () => {
273309
expect(puppeteer.connect).toHaveBeenCalledWith({
274310
browserWSEndpoint: 'ws://localhost:9222/devtools/browser/xxx',
275311
defaultViewport: null,
312+
downloadBehavior: undefined,
276313
});
277314
// Should NOT call launch
278315
expect(puppeteer.launch).not.toHaveBeenCalled();
279316
});
280317

318+
test('should pass downloadPath to Puppeteer connect options when shareBrowserContext uses CDP', async () => {
319+
const config = {
320+
...mockBatchConfig,
321+
shareBrowserContext: true,
322+
files: ['web1.yml'],
323+
globalConfig: {
324+
web: {
325+
url: 'http://example.com',
326+
cdpEndpoint: 'ws://localhost:9222/devtools/browser/xxx',
327+
downloadPath: './downloads',
328+
},
329+
},
330+
};
331+
const runner = new BatchRunner(config);
332+
await runner.run();
333+
334+
expect(puppeteer.connect).toHaveBeenCalledWith({
335+
browserWSEndpoint: 'ws://localhost:9222/devtools/browser/xxx',
336+
defaultViewport: null,
337+
downloadBehavior: {
338+
policy: 'allow',
339+
downloadPath: path.resolve('./downloads'),
340+
},
341+
});
342+
});
343+
281344
test('should disconnect (not close) browser in CDP mode', async () => {
282345
const mockDisconnect = vi.fn();
283346
const mockClose = vi.fn().mockResolvedValue(undefined);

packages/cli/tests/unit-test/create-yaml-player.test.ts

Lines changed: 142 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,5 @@
11
import { readFileSync } from 'node:fs';
2+
import path from 'node:path';
23
import { createYamlPlayer, launchServer } from '@/create-yaml-player';
34
import type { MidsceneYamlScript, MidsceneYamlScriptEnv } from '@midscene/core';
45
import { processCacheConfig } from '@midscene/core/utils';
@@ -47,9 +48,25 @@ vi.mock('@midscene/web/bridge-mode', () => ({
4748
AgentOverChromeBridge: vi.fn(),
4849
}));
4950

50-
vi.mock('@midscene/web/puppeteer-agent-launcher', () => ({
51-
puppeteerAgentForTarget: vi.fn(),
52-
}));
51+
vi.mock('@midscene/web/puppeteer-agent-launcher', async (importOriginal) => {
52+
const original =
53+
await importOriginal<
54+
typeof import('@midscene/web/puppeteer-agent-launcher')
55+
>();
56+
return {
57+
...original,
58+
buildDownloadBehavior: (downloadPath: string | undefined) =>
59+
downloadPath
60+
? {
61+
policy: 'allow',
62+
downloadPath: downloadPath.startsWith('/')
63+
? downloadPath
64+
: `${process.cwd()}/${downloadPath.replace(/^\.\//, '')}`,
65+
}
66+
: undefined,
67+
puppeteerAgentForTarget: vi.fn(),
68+
};
69+
});
5370

5471
vi.mock('@midscene/web/puppeteer', () => ({
5572
PuppeteerAgent: vi.fn(),
@@ -275,6 +292,42 @@ describe('create-yaml-player', () => {
275292

276293
expect(result).toBe(mockPlayer);
277294
});
295+
296+
test('should pass web downloadPath to puppeteer agent launcher', async () => {
297+
const mockScript: MidsceneYamlScript = {
298+
web: {
299+
url: 'http://example.com',
300+
downloadPath: './downloads',
301+
},
302+
tasks: [],
303+
};
304+
const mockAgent = { destroy: vi.fn() };
305+
let setupFnCallback: (() => Promise<any>) | undefined;
306+
307+
vi.mocked(puppeteerAgentForTarget).mockResolvedValue({
308+
agent: mockAgent as any,
309+
freeFn: [],
310+
});
311+
vi.mocked(ScriptPlayer).mockImplementation((script, setupFn) => {
312+
setupFnCallback = setupFn as () => Promise<any>;
313+
return {
314+
addCleanup: vi.fn(),
315+
} as unknown as ScriptPlayer<MidsceneYamlScriptEnv>;
316+
});
317+
318+
await createYamlPlayer(mockFilePath, mockScript);
319+
await setupFnCallback?.();
320+
321+
expect(puppeteerAgentForTarget).toHaveBeenCalledWith(
322+
expect.objectContaining({
323+
url: 'http://example.com',
324+
downloadPath: './downloads',
325+
}),
326+
expect.any(Object),
327+
undefined,
328+
undefined,
329+
);
330+
});
278331
});
279332

280333
describe('Cache configuration - Legacy compatibility mode', () => {
@@ -821,6 +874,44 @@ describe('create-yaml-player', () => {
821874
);
822875
});
823876

877+
test('should warn that downloadPath is ignored in bridge mode', async () => {
878+
const mockScript: MidsceneYamlScript = {
879+
web: {
880+
url: 'http://example.com',
881+
bridgeMode: 'newTabWithUrl',
882+
downloadPath: './downloads',
883+
},
884+
tasks: [],
885+
};
886+
887+
const mockAgent = {
888+
destroy: vi.fn(),
889+
connectNewTabWithUrl: vi.fn().mockResolvedValue(undefined),
890+
};
891+
const warnSpy = vi.spyOn(console, 'warn').mockImplementation(() => {});
892+
let setupFnCallback: (() => Promise<any>) | undefined;
893+
894+
vi.mocked(readFileSync).mockReturnValue('mock yaml content');
895+
vi.mocked(parseYamlScript).mockReturnValue(mockScript);
896+
vi.mocked(AgentOverChromeBridge).mockImplementation(
897+
() => mockAgent as any,
898+
);
899+
vi.mocked(ScriptPlayer).mockImplementation((script, setupFn) => {
900+
setupFnCallback = setupFn as () => Promise<any>;
901+
return {
902+
addCleanup: vi.fn(),
903+
} as unknown as ScriptPlayer<MidsceneYamlScriptEnv>;
904+
});
905+
906+
await createYamlPlayer(mockFilePath, mockScript);
907+
await setupFnCallback?.();
908+
909+
expect(warnSpy).toHaveBeenCalledWith(
910+
expect.stringContaining('downloadPath'),
911+
);
912+
warnSpy.mockRestore();
913+
});
914+
824915
test('should handle undefined aiActionContext gracefully for Android', async () => {
825916
const mockScript: MidsceneYamlScript = {
826917
android: {
@@ -1300,6 +1391,7 @@ describe('create-yaml-player', () => {
13001391
expect(puppeteer.connect).toHaveBeenCalledWith({
13011392
browserWSEndpoint: 'ws://localhost:9222/devtools/browser/xxx',
13021393
defaultViewport: null,
1394+
downloadBehavior: undefined,
13031395
});
13041396

13051397
// Should reuse puppeteerAgentForTarget (page setup: viewport, userAgent, etc.)
@@ -1311,6 +1403,53 @@ describe('create-yaml-player', () => {
13111403
);
13121404
});
13131405

1406+
test('should configure download behavior via Puppeteer connect options in CDP mode', async () => {
1407+
const mockScript: MidsceneYamlScript = {
1408+
web: {
1409+
url: 'http://example.com',
1410+
cdpEndpoint: 'ws://localhost:9222/devtools/browser/xxx',
1411+
downloadPath: './downloads',
1412+
},
1413+
tasks: [],
1414+
};
1415+
1416+
const mockBrowser = {
1417+
disconnect: vi.fn(),
1418+
};
1419+
const mockAgent = { destroy: vi.fn() };
1420+
let setupFnCallback: (() => Promise<any>) | undefined;
1421+
1422+
vi.mocked(readFileSync).mockReturnValue('mock yaml content');
1423+
vi.mocked(parseYamlScript).mockReturnValue(mockScript);
1424+
1425+
const puppeteer = (await import('puppeteer')).default;
1426+
vi.mocked(puppeteer.connect).mockResolvedValue(mockBrowser as any);
1427+
1428+
vi.mocked(puppeteerAgentForTarget).mockResolvedValue({
1429+
agent: mockAgent as any,
1430+
freeFn: [],
1431+
});
1432+
1433+
vi.mocked(ScriptPlayer).mockImplementation((script, setupFn) => {
1434+
setupFnCallback = setupFn as () => Promise<any>;
1435+
return {
1436+
addCleanup: vi.fn(),
1437+
} as unknown as ScriptPlayer<MidsceneYamlScriptEnv>;
1438+
});
1439+
1440+
await createYamlPlayer(mockFilePath, mockScript);
1441+
await setupFnCallback?.();
1442+
1443+
expect(puppeteer.connect).toHaveBeenCalledWith({
1444+
browserWSEndpoint: 'ws://localhost:9222/devtools/browser/xxx',
1445+
defaultViewport: null,
1446+
downloadBehavior: {
1447+
policy: 'allow',
1448+
downloadPath: path.resolve('./downloads'),
1449+
},
1450+
});
1451+
});
1452+
13141453
test('should pass agent options in CDP mode via puppeteerAgentForTarget', async () => {
13151454
const mockScript: MidsceneYamlScript = {
13161455
web: {

0 commit comments

Comments
 (0)