Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 3 additions & 5 deletions packages/cli/src/config-factory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,19 +59,17 @@ async function expandFilePatterns(
basePath: string,
): Promise<string[]> {
const allFiles: string[] = [];
const seenFiles = new Set<string>();

for (const pattern of patterns) {
try {
const yamlFiles = await matchYamlFiles(pattern, {
cwd: basePath,
});

// Add all matched files, including duplicates
// This allows users to execute the same file multiple times
for (const file of yamlFiles) {
if (!seenFiles.has(file)) {
seenFiles.add(file);
allFiles.push(file);
}
allFiles.push(file);
}
} catch (error) {
console.warn(`Warning: Failed to expand pattern "${pattern}":`, error);
Expand Down
47 changes: 27 additions & 20 deletions packages/cli/src/create-yaml-player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,11 @@ export async function createYamlPlayer(
): Promise<ScriptPlayer<MidsceneYamlScriptEnv>> {
const yamlScript =
script || parseYamlScript(readFileSync(file, 'utf-8'), file);

// Deep clone the script to avoid mutation issues when the same file is executed multiple times
// This ensures each ScriptPlayer instance has its own independent copy of the YAML data
const clonedYamlScript = structuredClone(yamlScript);

const fileName = basename(file, extname(file));
const preference = {
headed: options?.headed,
Expand All @@ -60,25 +65,27 @@ export async function createYamlPlayer(
};

const player = new ScriptPlayer(
yamlScript,
clonedYamlScript,
async () => {
const freeFn: FreeFn[] = [];
const webTarget = yamlScript.web || yamlScript.target;
const webTarget = clonedYamlScript.web || clonedYamlScript.target;

// Validate that only one target type is specified
const targetCount = [
typeof webTarget !== 'undefined',
typeof yamlScript.android !== 'undefined',
typeof yamlScript.ios !== 'undefined',
typeof yamlScript.interface !== 'undefined',
typeof clonedYamlScript.android !== 'undefined',
typeof clonedYamlScript.ios !== 'undefined',
typeof clonedYamlScript.interface !== 'undefined',
].filter(Boolean).length;

if (targetCount > 1) {
const specifiedTargets = [
typeof webTarget !== 'undefined' ? 'web' : null,
typeof yamlScript.android !== 'undefined' ? 'android' : null,
typeof yamlScript.ios !== 'undefined' ? 'ios' : null,
typeof yamlScript.interface !== 'undefined' ? 'interface' : null,
typeof clonedYamlScript.android !== 'undefined' ? 'android' : null,
typeof clonedYamlScript.ios !== 'undefined' ? 'ios' : null,
typeof clonedYamlScript.interface !== 'undefined'
? 'interface'
: null,
].filter(Boolean);

throw new Error(
Expand All @@ -88,7 +95,7 @@ export async function createYamlPlayer(

// handle new web config
if (typeof webTarget !== 'undefined') {
if (typeof yamlScript.target !== 'undefined') {
if (typeof clonedYamlScript.target !== 'undefined') {
console.warn(
'target is deprecated, please use web instead. See https://midscenejs.com/automate-with-scripts-in-yaml for more information. Sorry for the inconvenience.',
);
Expand Down Expand Up @@ -123,7 +130,7 @@ export async function createYamlPlayer(
{
...preference,
cache: processCacheConfig(
yamlScript.agent?.cache,
clonedYamlScript.agent?.cache,
fileName,
fileName,
),
Expand Down Expand Up @@ -156,7 +163,7 @@ export async function createYamlPlayer(
const agent = new AgentOverChromeBridge({
closeNewTabsAfterDisconnect: webTarget.closeNewTabsAfterDisconnect,
cache: processCacheConfig(
yamlScript.agent?.cache,
clonedYamlScript.agent?.cache,
fileName,
fileName,
),
Expand All @@ -183,11 +190,11 @@ export async function createYamlPlayer(
}

// handle android
if (typeof yamlScript.android !== 'undefined') {
const androidTarget = yamlScript.android;
if (typeof clonedYamlScript.android !== 'undefined') {
const androidTarget = clonedYamlScript.android;
const agent = await agentFromAdbDevice(androidTarget?.deviceId, {
cache: processCacheConfig(
yamlScript.agent?.cache,
clonedYamlScript.agent?.cache,
fileName,
fileName,
),
Expand All @@ -206,8 +213,8 @@ export async function createYamlPlayer(
}

// handle iOS
if (typeof yamlScript.ios !== 'undefined') {
const iosTarget = yamlScript.ios;
if (typeof clonedYamlScript.ios !== 'undefined') {
const iosTarget = clonedYamlScript.ios;
const agent = await agentFromWebDriverAgent({
wdaPort: iosTarget?.wdaPort,
wdaHost: iosTarget?.wdaHost,
Expand All @@ -226,8 +233,8 @@ export async function createYamlPlayer(
}

// handle general interface
if (typeof yamlScript.interface !== 'undefined') {
const interfaceTarget = yamlScript.interface;
if (typeof clonedYamlScript.interface !== 'undefined') {
const interfaceTarget = clonedYamlScript.interface;

const moduleSpecifier = interfaceTarget.module;
let finalModuleSpecifier: string;
Expand Down Expand Up @@ -269,9 +276,9 @@ export async function createYamlPlayer(
// create agent from device
debug('creating agent from device', device);
const agent = createAgent(device, {
...yamlScript.agent,
...clonedYamlScript.agent,
cache: processCacheConfig(
yamlScript.agent?.cache,
clonedYamlScript.agent?.cache,
fileName,
fileName,
),
Expand Down
35 changes: 33 additions & 2 deletions packages/cli/tests/unit-test/config-factory.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -134,6 +134,31 @@ summary: "yaml-summary.json"
'No YAML files found matching the patterns in "files"',
);
});

test('should preserve duplicate file entries', async () => {
const mockYamlContent = `
files:
- "login.yml"
- "test.yml"
- "login.yml"
`;
const mockParsedYaml = {
files: ['login.yml', 'test.yml', 'login.yml'],
};

vi.mocked(readFileSync).mockReturnValue(mockYamlContent);
vi.mocked(interpolateEnvVars).mockReturnValue(mockYamlContent);
vi.mocked(yamlLoad).mockReturnValue(mockParsedYaml);
vi.mocked(matchYamlFiles)
.mockResolvedValueOnce(['login.yml'])
.mockResolvedValueOnce(['test.yml'])
.mockResolvedValueOnce(['login.yml']);

const result = await parseConfigYaml(mockIndexPath);

expect(result.files).toEqual(['login.yml', 'test.yml', 'login.yml']);
expect(result.files.length).toBe(3);
});
});

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

const result = await createFilesConfig(patterns);

// Note: test1.yml appears twice because it's matched by both patterns
// This is expected behavior - patterns are evaluated independently
expect(result).toEqual({
files: expandedFiles,
files: ['test1.yml', 'test1.yml', 'testA.yml', 'testB.yml'],
concurrent: 1,
continueOnError: false,
shareBrowserContext: false,
Expand All @@ -290,6 +320,7 @@ concurrent: 2
globalConfig: {
web: undefined,
android: undefined,
ios: undefined,
},
});
expect(matchYamlFiles).toHaveBeenCalledWith(patterns[0], {
Expand Down
15 changes: 6 additions & 9 deletions packages/core/src/yaml/player.ts
Original file line number Diff line number Diff line change
Expand Up @@ -465,19 +465,16 @@ export class ScriptPlayer<T extends MidsceneYamlScriptEnv> {
`unknown flowItem in yaml: ${JSON.stringify(flowItem)}`,
);

assert(
!((flowItem as any).prompt && locatePromptShortcut),
`conflict locate prompt for item: ${JSON.stringify(flowItem)}`,
);

if (locatePromptShortcut) {
(flowItem as any).prompt = locatePromptShortcut;
}
// Create a new object instead of mutating the original flowItem
// This prevents issues when the same YAML script is executed multiple times
const flowItemForProcessing = locatePromptShortcut
? { ...flowItem, prompt: locatePromptShortcut }
: flowItem;

const { locateParam, restParams } =
buildDetailedLocateParamAndRestParams(
locatePromptShortcut || '',
flowItem as LocateOption,
flowItemForProcessing as LocateOption,
[
matchedAction.name,
matchedAction.interfaceAlias || '_never_mind_',
Expand Down