Skip to content

Commit c1a3398

Browse files
Copilotquanru
andauthored
fix(cli): allow duplicate YAML files in config.yaml (#1327)
* Initial plan * fix(cli): allow duplicate YAML files in config.yaml Co-authored-by: quanru <[email protected]> * fix(cli): deep clone YAML script to prevent mutation issues * fix(yaml): prevent mutation of flowItem by creating a new object for processing --------- Co-authored-by: copilot-swe-agent[bot] <[email protected]> Co-authored-by: quanru <[email protected]> Co-authored-by: quanruzhuoxiu <[email protected]>
1 parent 8e1c9c2 commit c1a3398

File tree

4 files changed

+69
-36
lines changed

4 files changed

+69
-36
lines changed

packages/cli/src/config-factory.ts

Lines changed: 3 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -59,19 +59,17 @@ async function expandFilePatterns(
5959
basePath: string,
6060
): Promise<string[]> {
6161
const allFiles: string[] = [];
62-
const seenFiles = new Set<string>();
6362

6463
for (const pattern of patterns) {
6564
try {
6665
const yamlFiles = await matchYamlFiles(pattern, {
6766
cwd: basePath,
6867
});
6968

69+
// Add all matched files, including duplicates
70+
// This allows users to execute the same file multiple times
7071
for (const file of yamlFiles) {
71-
if (!seenFiles.has(file)) {
72-
seenFiles.add(file);
73-
allFiles.push(file);
74-
}
72+
allFiles.push(file);
7573
}
7674
} catch (error) {
7775
console.warn(`Warning: Failed to expand pattern "${pattern}":`, error);

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

Lines changed: 27 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,11 @@ export async function createYamlPlayer(
5252
): Promise<ScriptPlayer<MidsceneYamlScriptEnv>> {
5353
const yamlScript =
5454
script || parseYamlScript(readFileSync(file, 'utf-8'), file);
55+
56+
// Deep clone the script to avoid mutation issues when the same file is executed multiple times
57+
// This ensures each ScriptPlayer instance has its own independent copy of the YAML data
58+
const clonedYamlScript = structuredClone(yamlScript);
59+
5560
const fileName = basename(file, extname(file));
5661
const preference = {
5762
headed: options?.headed,
@@ -60,25 +65,27 @@ export async function createYamlPlayer(
6065
};
6166

6267
const player = new ScriptPlayer(
63-
yamlScript,
68+
clonedYamlScript,
6469
async () => {
6570
const freeFn: FreeFn[] = [];
66-
const webTarget = yamlScript.web || yamlScript.target;
71+
const webTarget = clonedYamlScript.web || clonedYamlScript.target;
6772

6873
// Validate that only one target type is specified
6974
const targetCount = [
7075
typeof webTarget !== 'undefined',
71-
typeof yamlScript.android !== 'undefined',
72-
typeof yamlScript.ios !== 'undefined',
73-
typeof yamlScript.interface !== 'undefined',
76+
typeof clonedYamlScript.android !== 'undefined',
77+
typeof clonedYamlScript.ios !== 'undefined',
78+
typeof clonedYamlScript.interface !== 'undefined',
7479
].filter(Boolean).length;
7580

7681
if (targetCount > 1) {
7782
const specifiedTargets = [
7883
typeof webTarget !== 'undefined' ? 'web' : null,
79-
typeof yamlScript.android !== 'undefined' ? 'android' : null,
80-
typeof yamlScript.ios !== 'undefined' ? 'ios' : null,
81-
typeof yamlScript.interface !== 'undefined' ? 'interface' : null,
84+
typeof clonedYamlScript.android !== 'undefined' ? 'android' : null,
85+
typeof clonedYamlScript.ios !== 'undefined' ? 'ios' : null,
86+
typeof clonedYamlScript.interface !== 'undefined'
87+
? 'interface'
88+
: null,
8289
].filter(Boolean);
8390

8491
throw new Error(
@@ -88,7 +95,7 @@ export async function createYamlPlayer(
8895

8996
// handle new web config
9097
if (typeof webTarget !== 'undefined') {
91-
if (typeof yamlScript.target !== 'undefined') {
98+
if (typeof clonedYamlScript.target !== 'undefined') {
9299
console.warn(
93100
'target is deprecated, please use web instead. See https://midscenejs.com/automate-with-scripts-in-yaml for more information. Sorry for the inconvenience.',
94101
);
@@ -123,7 +130,7 @@ export async function createYamlPlayer(
123130
{
124131
...preference,
125132
cache: processCacheConfig(
126-
yamlScript.agent?.cache,
133+
clonedYamlScript.agent?.cache,
127134
fileName,
128135
fileName,
129136
),
@@ -156,7 +163,7 @@ export async function createYamlPlayer(
156163
const agent = new AgentOverChromeBridge({
157164
closeNewTabsAfterDisconnect: webTarget.closeNewTabsAfterDisconnect,
158165
cache: processCacheConfig(
159-
yamlScript.agent?.cache,
166+
clonedYamlScript.agent?.cache,
160167
fileName,
161168
fileName,
162169
),
@@ -183,11 +190,11 @@ export async function createYamlPlayer(
183190
}
184191

185192
// handle android
186-
if (typeof yamlScript.android !== 'undefined') {
187-
const androidTarget = yamlScript.android;
193+
if (typeof clonedYamlScript.android !== 'undefined') {
194+
const androidTarget = clonedYamlScript.android;
188195
const agent = await agentFromAdbDevice(androidTarget?.deviceId, {
189196
cache: processCacheConfig(
190-
yamlScript.agent?.cache,
197+
clonedYamlScript.agent?.cache,
191198
fileName,
192199
fileName,
193200
),
@@ -206,8 +213,8 @@ export async function createYamlPlayer(
206213
}
207214

208215
// handle iOS
209-
if (typeof yamlScript.ios !== 'undefined') {
210-
const iosTarget = yamlScript.ios;
216+
if (typeof clonedYamlScript.ios !== 'undefined') {
217+
const iosTarget = clonedYamlScript.ios;
211218
const agent = await agentFromWebDriverAgent({
212219
wdaPort: iosTarget?.wdaPort,
213220
wdaHost: iosTarget?.wdaHost,
@@ -226,8 +233,8 @@ export async function createYamlPlayer(
226233
}
227234

228235
// handle general interface
229-
if (typeof yamlScript.interface !== 'undefined') {
230-
const interfaceTarget = yamlScript.interface;
236+
if (typeof clonedYamlScript.interface !== 'undefined') {
237+
const interfaceTarget = clonedYamlScript.interface;
231238

232239
const moduleSpecifier = interfaceTarget.module;
233240
let finalModuleSpecifier: string;
@@ -269,9 +276,9 @@ export async function createYamlPlayer(
269276
// create agent from device
270277
debug('creating agent from device', device);
271278
const agent = createAgent(device, {
272-
...yamlScript.agent,
279+
...clonedYamlScript.agent,
273280
cache: processCacheConfig(
274-
yamlScript.agent?.cache,
281+
clonedYamlScript.agent?.cache,
275282
fileName,
276283
fileName,
277284
),

packages/cli/tests/unit-test/config-factory.test.ts

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -134,6 +134,31 @@ summary: "yaml-summary.json"
134134
'No YAML files found matching the patterns in "files"',
135135
);
136136
});
137+
138+
test('should preserve duplicate file entries', async () => {
139+
const mockYamlContent = `
140+
files:
141+
- "login.yml"
142+
- "test.yml"
143+
- "login.yml"
144+
`;
145+
const mockParsedYaml = {
146+
files: ['login.yml', 'test.yml', 'login.yml'],
147+
};
148+
149+
vi.mocked(readFileSync).mockReturnValue(mockYamlContent);
150+
vi.mocked(interpolateEnvVars).mockReturnValue(mockYamlContent);
151+
vi.mocked(yamlLoad).mockReturnValue(mockParsedYaml);
152+
vi.mocked(matchYamlFiles)
153+
.mockResolvedValueOnce(['login.yml'])
154+
.mockResolvedValueOnce(['test.yml'])
155+
.mockResolvedValueOnce(['login.yml']);
156+
157+
const result = await parseConfigYaml(mockIndexPath);
158+
159+
expect(result.files).toEqual(['login.yml', 'test.yml', 'login.yml']);
160+
expect(result.files.length).toBe(3);
161+
});
137162
});
138163

139164
describe('createConfig', () => {
@@ -273,12 +298,17 @@ concurrent: 2
273298
test('should create config with default options and expand patterns', async () => {
274299
const patterns = ['test1.yml', 'test*.yml'];
275300
const expandedFiles = ['test1.yml', 'testA.yml', 'testB.yml'];
276-
vi.mocked(matchYamlFiles).mockResolvedValue(expandedFiles);
301+
// Mock to return different results for each pattern call
302+
vi.mocked(matchYamlFiles)
303+
.mockResolvedValueOnce(['test1.yml'])
304+
.mockResolvedValueOnce(['test1.yml', 'testA.yml', 'testB.yml']);
277305

278306
const result = await createFilesConfig(patterns);
279307

308+
// Note: test1.yml appears twice because it's matched by both patterns
309+
// This is expected behavior - patterns are evaluated independently
280310
expect(result).toEqual({
281-
files: expandedFiles,
311+
files: ['test1.yml', 'test1.yml', 'testA.yml', 'testB.yml'],
282312
concurrent: 1,
283313
continueOnError: false,
284314
shareBrowserContext: false,
@@ -290,6 +320,7 @@ concurrent: 2
290320
globalConfig: {
291321
web: undefined,
292322
android: undefined,
323+
ios: undefined,
293324
},
294325
});
295326
expect(matchYamlFiles).toHaveBeenCalledWith(patterns[0], {

packages/core/src/yaml/player.ts

Lines changed: 6 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -465,19 +465,16 @@ export class ScriptPlayer<T extends MidsceneYamlScriptEnv> {
465465
`unknown flowItem in yaml: ${JSON.stringify(flowItem)}`,
466466
);
467467

468-
assert(
469-
!((flowItem as any).prompt && locatePromptShortcut),
470-
`conflict locate prompt for item: ${JSON.stringify(flowItem)}`,
471-
);
472-
473-
if (locatePromptShortcut) {
474-
(flowItem as any).prompt = locatePromptShortcut;
475-
}
468+
// Create a new object instead of mutating the original flowItem
469+
// This prevents issues when the same YAML script is executed multiple times
470+
const flowItemForProcessing = locatePromptShortcut
471+
? { ...flowItem, prompt: locatePromptShortcut }
472+
: flowItem;
476473

477474
const { locateParam, restParams } =
478475
buildDetailedLocateParamAndRestParams(
479476
locatePromptShortcut || '',
480-
flowItem as LocateOption,
477+
flowItemForProcessing as LocateOption,
481478
[
482479
matchedAction.name,
483480
matchedAction.interfaceAlias || '_never_mind_',

0 commit comments

Comments
 (0)