From 4a69b7b2eae7b18496f7740363709dfc2f0c6d8e Mon Sep 17 00:00:00 2001 From: rishishanbhag Date: Thu, 11 Jun 2026 13:52:19 +0530 Subject: [PATCH] fix: scope getDynamicEmail to referenced placeholder (fixes #53) Resolve {{email.extract(...)}} against run-scoped inbox when steps only use {{run.dynamicEmail}}, while preserving global inbox for cross-call OTP flows. --- src/__tests__/data-cache.test.ts | 118 ++++++++++++++++++++++++++++--- src/data-cache.ts | 110 +++++++++++++++++++++++++++- src/index.ts | 19 ++--- 3 files changed, 228 insertions(+), 19 deletions(-) diff --git a/src/__tests__/data-cache.test.ts b/src/__tests__/data-cache.test.ts index f5d90db..60538ec 100644 --- a/src/__tests__/data-cache.test.ts +++ b/src/__tests__/data-cache.test.ts @@ -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"; @@ -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}}"], + ); + }); +}); diff --git a/src/data-cache.ts b/src/data-cache.ts index 216c24e..c7d08d3 100644 --- a/src/data-cache.ts +++ b/src/data-cache.ts @@ -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; }; // ============================================================================= @@ -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; + 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. */ @@ -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(); @@ -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}}"]; + } } /** diff --git a/src/index.ts b/src/index.ts index 1e4d648..cd37e7b 100644 --- a/src/index.ts +++ b/src/index.ts @@ -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.`); @@ -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];