Skip to content
Open
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
118 changes: 109 additions & 9 deletions src/__tests__/data-cache.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,11 +10,13 @@ vi.mock("../email", () => ({

import { resetConfig, configure } from "../config";
import {
computeDynamicEmailPreference,
containsGlobalPlaceholder,
stepsContainGlobalPlaceholders,
replacePlaceholders,
generateLocalValues,
getDynamicEmail,
processPlaceholders,
LocalPlaceholders,
GlobalPlaceholders,
} from "../data-cache";
Expand Down Expand Up @@ -152,21 +154,119 @@ describe("generateLocalValues", () => {
});
});

// ─── computeDynamicEmailPreference ──────────────────────────────────────────

describe("computeDynamicEmailPreference", () => {
it('returns "global" when steps reference {{global.dynamicEmail}}', () => {
const steps = [{ description: "Fill email", data: { value: "{{global.dynamicEmail}}" } }];
expect(computeDynamicEmailPreference(steps)).toBe("global");
});

it('returns "run" when steps only reference {{run.dynamicEmail}}', () => {
const steps = [
{ description: "Fill email", data: { value: "{{run.dynamicEmail}}" } },
{ description: "Fill OTP", data: { value: "{{email.otp:get the OTP}}" } },
];
expect(computeDynamicEmailPreference(steps)).toBe("run");
});

it('returns "global" when both run and global dynamicEmail are referenced', () => {
const steps = [
{ description: "Fill email", data: { value: "{{global.dynamicEmail}}" } },
{ description: "Backup", data: { value: "{{run.dynamicEmail}}" } },
];
expect(computeDynamicEmailPreference(steps)).toBe("global");
});

it('returns "auto" when no dynamicEmail placeholder is referenced', () => {
const steps = [
{ description: "Fill OTP", data: { value: "{{email.otp:get the OTP}}" } },
{ description: "Enter name", data: { value: "{{run.fullName}}" } },
];
expect(computeDynamicEmailPreference(steps)).toBe("auto");
});

it('returns "global" when assertions reference {{global.dynamicEmail}}', () => {
const steps = [{ description: "Submit form" }];
const assertions = [{ assertion: "Email sent to {{global.dynamicEmail}}" }];
expect(computeDynamicEmailPreference(steps, assertions)).toBe("global");
});
});

// ─── getDynamicEmail ────────────────────────────────────────────────────────

describe("getDynamicEmail", () => {
it("prefers global dynamicEmail over local", () => {
const globalValues: GlobalPlaceholders = {
"{{global.shortid}}": "g1",
"{{global.fullName}}": "Global User",
"{{global.email}}": "g@test.com",
"{{global.dynamicEmail}}": "global-dyn@test.com",
"{{global.phoneNumber}}": "0000000000",
};
expect(getDynamicEmail(localValues, globalValues)).toBe("global-dyn@test.com");
const globalValues: GlobalPlaceholders = {
"{{global.shortid}}": "g1",
"{{global.fullName}}": "Global User",
"{{global.email}}": "g@test.com",
"{{global.dynamicEmail}}": "global-dyn@test.com",
"{{global.phoneNumber}}": "0000000000",
};

it('uses run inbox when preference is "run" even if global is present', () => {
expect(getDynamicEmail(localValues, globalValues, "run")).toBe("dyn@test.com");
});

it('uses global inbox when preference is "global"', () => {
expect(getDynamicEmail(localValues, globalValues, "global")).toBe("global-dyn@test.com");
});

it('prefers global dynamicEmail over local when preference is "auto"', () => {
expect(getDynamicEmail(localValues, globalValues, "auto")).toBe("global-dyn@test.com");
});

it("falls back to local dynamicEmail when global is not provided", () => {
expect(getDynamicEmail(localValues)).toBe("dyn@test.com");
});
});

// ─── processPlaceholders ────────────────────────────────────────────────────

describe("processPlaceholders dynamicEmailPreference", () => {
beforeEach(() => {
configure({ email: { domain: "test.com", extractContent: vi.fn() } });
});

it('sets dynamicEmailPreference to "run" for run-only step sets with executionId', async () => {
const result = await processPlaceholders(
[
{ description: "Fill email", data: { value: "{{run.dynamicEmail}}" } },
{ description: "Fill OTP", data: { value: "{{email.otp:get the OTP}}" } },
],
undefined,
"exec-1",
);

expect(result.dynamicEmailPreference).toBe("run");
expect(getDynamicEmail(result.localValues, result.globalValues, result.dynamicEmailPreference)).toBe(
result.localValues["{{run.dynamicEmail}}"],
);
expect(
getDynamicEmail(result.localValues, result.globalValues, result.dynamicEmailPreference),
).not.toBe(result.globalValues?.["{{global.dynamicEmail}}"]);
});

it('sets dynamicEmailPreference to "global" when steps reference {{global.dynamicEmail}}', async () => {
const result = await processPlaceholders(
[{ description: "Fill email", data: { value: "{{global.dynamicEmail}}" } }],
undefined,
"exec-1",
);

expect(result.dynamicEmailPreference).toBe("global");
});

it('sets dynamicEmailPreference to "auto" when no dynamicEmail placeholder is referenced', async () => {
const result = await processPlaceholders(
[{ description: "Fill OTP", data: { value: "{{email.otp:get the OTP}}" } }],
undefined,
"exec-1",
);

expect(result.dynamicEmailPreference).toBe("auto");
expect(getDynamicEmail(result.localValues, result.globalValues, result.dynamicEmailPreference)).toBe(
result.globalValues?.["{{global.dynamicEmail}}"],
);
});
});
110 changes: 108 additions & 2 deletions src/data-cache.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,12 +61,15 @@ export type AssertionItem = {
video?: boolean;
};

export type DynamicEmailPreference = "run" | "global" | "auto";

export type ProcessPlaceholdersResult = {
processedSteps: Step[];
processedAssertions?: AssertionItem[];
localValues: LocalPlaceholders;
globalValues?: GlobalPlaceholders;
projectDataValues?: ProjectDataPlaceholders;
dynamicEmailPreference: DynamicEmailPreference;
};

// =============================================================================
Expand Down Expand Up @@ -295,6 +298,95 @@ export function assertionsContainGlobalPlaceholders(assertions?: { assertion: st
return false;
}

/**
* Checks if text contains a specific placeholder string.
*/
export function containsPlaceholder(text: string, placeholder: string): boolean {
return text.includes(placeholder);
}

/**
* Scans steps for a specific placeholder string.
*/
export function stepsContainPlaceholder(
steps: {
description: string;
data?: Record<string, string>;
script?: string;
waitUntil?: string;
}[],
placeholder: string,
): boolean {
for (const step of steps) {
if (containsPlaceholder(step.description, placeholder)) {
return true;
}

if (step.data) {
for (const value of Object.values(step.data)) {
if (containsPlaceholder(value, placeholder)) {
return true;
}
}
}

if (step.script && containsPlaceholder(step.script, placeholder)) {
return true;
}

if (step.waitUntil && containsPlaceholder(step.waitUntil, placeholder)) {
return true;
}
}

return false;
}

/**
* Scans assertions for a specific placeholder string.
*/
export function assertionsContainPlaceholder(
assertions: { assertion: string }[] | undefined,
placeholder: string,
): boolean {
if (!assertions) return false;

for (const item of assertions) {
if (containsPlaceholder(item.assertion, placeholder)) {
return true;
}
}

return false;
}

/**
* Determines which dynamic email inbox {{email.extract(...)}} should query
* based on which dynamicEmail placeholder the step set references.
*/
export function computeDynamicEmailPreference(
steps: Step[],
assertions?: AssertionItem[],
): DynamicEmailPreference {
const referencesGlobalDynamicEmail =
stepsContainPlaceholder(steps, "{{global.dynamicEmail}}") ||
assertionsContainPlaceholder(assertions, "{{global.dynamicEmail}}");

const referencesRunDynamicEmail =
stepsContainPlaceholder(steps, "{{run.dynamicEmail}}") ||
assertionsContainPlaceholder(assertions, "{{run.dynamicEmail}}");

if (referencesGlobalDynamicEmail) {
return "global";
}

if (referencesRunDynamicEmail) {
return "run";
}

return "auto";
}

/**
* Checks if any text contains project data placeholders.
*/
Expand Down Expand Up @@ -441,6 +533,8 @@ export async function processPlaceholders(
);
}

const dynamicEmailPreference = computeDynamicEmailPreference(steps, assertions);

// Generate fresh run values (always new per runSteps call)
const localValues = await generateLocalValues();

Expand Down Expand Up @@ -527,18 +621,30 @@ export async function processPlaceholders(
localValues,
globalValues,
projectDataValues,
dynamicEmailPreference,
};
}

/**
* Gets the dynamic email to use for email extraction.
* Prefers global email if available, otherwise falls back to local email.
* When preference is "run", always uses the run-scoped inbox.
* When preference is "global", prefers the shared execution inbox.
* When preference is "auto" (no dynamicEmail placeholder referenced), prefers global if available.
*/
export function getDynamicEmail(
localValues: LocalPlaceholders,
globalValues?: GlobalPlaceholders,
preference: DynamicEmailPreference = "auto",
): string {
return globalValues?.["{{global.dynamicEmail}}"] || localValues["{{run.dynamicEmail}}"];
switch (preference) {
case "run":
return localValues["{{run.dynamicEmail}}"];
case "global":
return globalValues?.["{{global.dynamicEmail}}"] || localValues["{{run.dynamicEmail}}"];
case "auto":
default:
return globalValues?.["{{global.dynamicEmail}}"] || localValues["{{run.dynamicEmail}}"];
}
}

/**
Expand Down
19 changes: 11 additions & 8 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -137,8 +137,14 @@ export const runSteps = async ({
}

// Process dynamic placeholders before running steps
const { processedSteps, processedAssertions, localValues, globalValues, projectDataValues } =
await processPlaceholders(steps, assertions, executionId, projectId);
const {
processedSteps,
processedAssertions,
localValues,
globalValues,
projectDataValues,
dynamicEmailPreference,
} = await processPlaceholders(steps, assertions, executionId, projectId);

logger.info(`Starting step-by-step execution of ${processedSteps.length} steps.`);

Expand All @@ -163,12 +169,9 @@ export const runSteps = async ({
let errorInStepExecution,
stepThatFailed: string = "";
for (let i = 0; i < processedSteps.length; i++) {
// Resolve email placeholders lazily just before step execution
// This ensures the email has arrived before we try to extract content
// Use global email if available, otherwise fall back to run email, and then use the supplied email from regex

// ~~~ This logic needs to be fixed as global email will always be present if executionId is provided ~~~
const dynamicEmail = getDynamicEmail(localValues, globalValues);
// Resolve email placeholders lazily just before step execution.
// This ensures the email has arrived before we try to extract content.
const dynamicEmail = getDynamicEmail(localValues, globalValues, dynamicEmailPreference);

// Re-process step data and waitUntil with current localValues to pick up extracted values from previous steps
let currentStep = processedSteps[i];
Expand Down