@elliemae/encw-leak-runner 1.0.3 → 1.0.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (138) hide show
  1. package/CHANGELOG.md +23 -0
  2. package/README.md +34 -17
  3. package/dist/.tsbuildinfo +1 -1
  4. package/dist/bin/leak-runner.js +80 -11
  5. package/dist/cjs/cli/commands/runCommand.js +7 -3
  6. package/dist/cjs/config/missingRequiredParamError.js +1 -1
  7. package/dist/cjs/config/requiredEnvParams.js +13 -2
  8. package/dist/cjs/config/runnerConfigLoader.js +6 -3
  9. package/dist/cjs/config/runnerConfigSchema.js +5 -1
  10. package/dist/cjs/config/unknownEnvError.js +36 -0
  11. package/dist/cjs/index.js +1 -0
  12. package/dist/cjs/runner/scenarioRunner.js +9 -1
  13. package/dist/cjs/scenarios/one-admin/export-navigation.scenario.js +10 -1
  14. package/dist/cjs/scenarios/one-admin/page-models/AdminLandingPageModel.js +42 -0
  15. package/dist/cjs/scenarios/one-admin/page-models/index.js +2 -0
  16. package/dist/esm/cli/commands/runCommand.js +11 -4
  17. package/dist/esm/config/missingRequiredParamError.js +1 -1
  18. package/dist/esm/config/requiredEnvParams.js +13 -2
  19. package/dist/esm/config/runnerConfigLoader.js +6 -3
  20. package/dist/esm/config/runnerConfigSchema.js +5 -1
  21. package/dist/esm/config/unknownEnvError.js +16 -0
  22. package/dist/esm/index.js +3 -1
  23. package/dist/esm/runner/scenarioRunner.js +9 -1
  24. package/dist/esm/scenarios/one-admin/export-navigation.scenario.js +11 -1
  25. package/dist/esm/scenarios/one-admin/page-models/AdminLandingPageModel.js +22 -0
  26. package/dist/esm/scenarios/one-admin/page-models/index.js +2 -0
  27. package/dist/types/lib/config/requiredEnvParams.d.ts +4 -2
  28. package/dist/types/lib/config/runnerConfigLoader.d.ts +1 -0
  29. package/dist/types/lib/config/runnerConfigSchema.d.ts +7 -0
  30. package/dist/types/lib/config/unknownEnvError.d.ts +5 -0
  31. package/dist/types/lib/index.d.ts +1 -1
  32. package/dist/types/lib/runner/aiEnhancementStep.d.ts +4 -3
  33. package/dist/types/lib/scenarios/one-admin/page-models/AdminLandingPageModel.d.ts +8 -0
  34. package/dist/types/lib/scenarios/one-admin/page-models/index.d.ts +1 -0
  35. package/dist/types/lib/types/scenario.d.ts +1 -0
  36. package/leak-runner.config.json +18 -0
  37. package/leak-runner.schema.json +5 -0
  38. package/lib/cli/commands/runCommand.ts +15 -5
  39. package/lib/config/missingRequiredParamError.ts +2 -2
  40. package/lib/config/requiredEnvParams.ts +14 -3
  41. package/lib/config/runnerConfigLoader.ts +12 -5
  42. package/lib/config/runnerConfigSchema.ts +4 -0
  43. package/lib/config/tests/fileConfigSource.test.ts +26 -0
  44. package/lib/config/tests/requiredEnvParams.test.ts +80 -5
  45. package/lib/config/tests/runnerConfigLoader.test.ts +30 -0
  46. package/lib/config/unknownEnvError.ts +13 -0
  47. package/lib/index.ts +1 -0
  48. package/lib/runner/aiEnhancementStep.ts +4 -3
  49. package/lib/runner/scenarioRunner.ts +8 -1
  50. package/lib/scenarios/one-admin/export-navigation.scenario.ts +13 -1
  51. package/lib/scenarios/one-admin/page-models/AdminLandingPageModel.ts +24 -0
  52. package/lib/scenarios/one-admin/page-models/index.ts +1 -0
  53. package/lib/types/scenario.ts +1 -0
  54. package/package.json +3 -3
  55. package/reports/analysis/index.html +1 -1
  56. package/reports/analysis/thresholdEvaluator.ts.html +1 -1
  57. package/reports/browser/iframeHeapProfiler.ts.html +1 -1
  58. package/reports/browser/index.html +1 -1
  59. package/reports/cli/commands/index.html +3 -3
  60. package/reports/cli/commands/listCommand.ts.html +1 -1
  61. package/reports/cli/commands/runCommand.ts.html +38 -8
  62. package/reports/cli/index.html +1 -1
  63. package/reports/cli/index.ts.html +1 -1
  64. package/reports/config/index.html +36 -21
  65. package/reports/config/missingRequiredParamError.ts.html +6 -6
  66. package/reports/config/requiredEnvParams.ts.html +61 -28
  67. package/reports/config/runnerConfigLoader.ts.html +38 -17
  68. package/reports/config/runnerConfigSchema.ts.html +18 -6
  69. package/reports/config/sources/cliOverrideConfigSource.ts.html +1 -1
  70. package/reports/config/sources/configSource.ts.html +2 -2
  71. package/reports/config/sources/envVarConfigSource.ts.html +1 -1
  72. package/reports/config/sources/fileConfigSource.ts.html +13 -13
  73. package/reports/config/sources/index.html +1 -1
  74. package/reports/config/unknownEnvError.ts.html +124 -0
  75. package/reports/index.html +35 -35
  76. package/reports/lcov-report/analysis/index.html +1 -1
  77. package/reports/lcov-report/analysis/thresholdEvaluator.ts.html +1 -1
  78. package/reports/lcov-report/browser/iframeHeapProfiler.ts.html +1 -1
  79. package/reports/lcov-report/browser/index.html +1 -1
  80. package/reports/lcov-report/cli/commands/index.html +3 -3
  81. package/reports/lcov-report/cli/commands/listCommand.ts.html +1 -1
  82. package/reports/lcov-report/cli/commands/runCommand.ts.html +38 -8
  83. package/reports/lcov-report/cli/index.html +1 -1
  84. package/reports/lcov-report/cli/index.ts.html +1 -1
  85. package/reports/lcov-report/config/index.html +36 -21
  86. package/reports/lcov-report/config/missingRequiredParamError.ts.html +6 -6
  87. package/reports/lcov-report/config/requiredEnvParams.ts.html +61 -28
  88. package/reports/lcov-report/config/runnerConfigLoader.ts.html +38 -17
  89. package/reports/lcov-report/config/runnerConfigSchema.ts.html +18 -6
  90. package/reports/lcov-report/config/sources/cliOverrideConfigSource.ts.html +1 -1
  91. package/reports/lcov-report/config/sources/configSource.ts.html +2 -2
  92. package/reports/lcov-report/config/sources/envVarConfigSource.ts.html +1 -1
  93. package/reports/lcov-report/config/sources/fileConfigSource.ts.html +13 -13
  94. package/reports/lcov-report/config/sources/index.html +1 -1
  95. package/reports/lcov-report/config/unknownEnvError.ts.html +124 -0
  96. package/reports/lcov-report/index.html +35 -35
  97. package/reports/lcov-report/registry/index.html +1 -1
  98. package/reports/lcov-report/registry/scenarioRegistry.ts.html +1 -1
  99. package/reports/lcov-report/reporting/consoleReporter.ts.html +1 -1
  100. package/reports/lcov-report/reporting/index.html +1 -1
  101. package/reports/lcov-report/reporting/junitReporter.ts.html +1 -1
  102. package/reports/lcov-report/runner/aiEnhancementStep.ts.html +8 -5
  103. package/reports/lcov-report/runner/batchRunner.ts.html +1 -1
  104. package/reports/lcov-report/runner/index.html +15 -15
  105. package/reports/lcov-report/runner/scenarioRunner.ts.html +32 -11
  106. package/reports/lcov-report/scenarios/index.html +1 -1
  107. package/reports/lcov-report/scenarios/index.ts.html +1 -1
  108. package/reports/lcov-report/scenarios/one-admin/export-navigation.scenario.ts.html +44 -8
  109. package/reports/lcov-report/scenarios/one-admin/index.html +11 -11
  110. package/reports/lcov-report/scenarios/one-admin/index.ts.html +1 -1
  111. package/reports/lcov-report/scenarios/one-admin/page-models/AdminLandingPageModel.ts.html +157 -0
  112. package/reports/lcov-report/scenarios/one-admin/page-models/ExportPageModel.ts.html +1 -1
  113. package/reports/lcov-report/scenarios/one-admin/page-models/SelectSettingsPageModel.ts.html +1 -1
  114. package/reports/lcov-report/scenarios/one-admin/page-models/index.html +19 -4
  115. package/reports/lcov-report/types/config.ts.html +1 -1
  116. package/reports/lcov-report/types/index.html +1 -1
  117. package/reports/lcov.info +301 -231
  118. package/reports/registry/index.html +1 -1
  119. package/reports/registry/scenarioRegistry.ts.html +1 -1
  120. package/reports/reporting/consoleReporter.ts.html +1 -1
  121. package/reports/reporting/index.html +1 -1
  122. package/reports/reporting/junitReporter.ts.html +1 -1
  123. package/reports/runner/aiEnhancementStep.ts.html +8 -5
  124. package/reports/runner/batchRunner.ts.html +1 -1
  125. package/reports/runner/index.html +15 -15
  126. package/reports/runner/scenarioRunner.ts.html +32 -11
  127. package/reports/scenarios/index.html +1 -1
  128. package/reports/scenarios/index.ts.html +1 -1
  129. package/reports/scenarios/one-admin/export-navigation.scenario.ts.html +44 -8
  130. package/reports/scenarios/one-admin/index.html +11 -11
  131. package/reports/scenarios/one-admin/index.ts.html +1 -1
  132. package/reports/scenarios/one-admin/page-models/AdminLandingPageModel.ts.html +157 -0
  133. package/reports/scenarios/one-admin/page-models/ExportPageModel.ts.html +1 -1
  134. package/reports/scenarios/one-admin/page-models/SelectSettingsPageModel.ts.html +1 -1
  135. package/reports/scenarios/one-admin/page-models/index.html +19 -4
  136. package/reports/types/config.ts.html +1 -1
  137. package/reports/types/index.html +1 -1
  138. package/test-report.xml +74 -62
@@ -62,6 +62,7 @@ class ScenarioRunner {
62
62
  scenario,
63
63
  snapshotsDir
64
64
  );
65
+ if (scenario.setup) await scenario.setup(page, frame);
65
66
  paths.before = await profiler.captureSnapshot("before");
66
67
  await this.repeatScenarioActions(scenario, page, frame);
67
68
  await forceGarbageCollection(page);
@@ -78,12 +79,19 @@ class ScenarioRunner {
78
79
  const auth = new AuthManager();
79
80
  const pageSetup = new PageSetup();
80
81
  await pageSetup.apply(page);
82
+ await page.unroute(PageSetup.BLOCKED_PATTERN);
81
83
  await auth.login(page, {
82
84
  username: this.config.env.userId,
83
85
  password: this.config.env.password,
84
86
  instanceId: this.config.env.instanceId
85
87
  });
86
- await page.goto(scenario.url());
88
+ process.stderr.write(`[scenarioRunner] post-login URL = ${page.url()}
89
+ `);
90
+ await page.waitForURL(`**${scenario.url()}**`, { timeout: 3e4 });
91
+ process.stderr.write(
92
+ `[scenarioRunner] settled URL before iframe = ${page.url()}
93
+ `
94
+ );
87
95
  const frame = await resolveIframe(page, scenario.microappSelector);
88
96
  fs.mkdirSync(snapshotsDir, { recursive: true });
89
97
  const profiler = new IframeHeapProfiler(page, frame, snapshotsDir);
@@ -1,4 +1,5 @@
1
1
  import {
2
+ AdminLandingPageModel,
2
3
  SelectSettingsPageModel,
3
4
  ExportPageModel
4
5
  } from "./page-models/index.js";
@@ -8,8 +9,17 @@ const exportNavigationScenario = {
8
9
  description: "Navigate to export page and come back - verify iframe GC",
9
10
  tags: ["critical"],
10
11
  microappSelector: "iframe#pui-iframe-container-emAdminUI",
11
- url: () => "/admin/oneadmin/migrate",
12
+ url: () => "/admin",
13
+ async setup(page) {
14
+ await AdminLandingPageModel.acceptCookiesIfShown(page);
15
+ },
12
16
  async action(page, frame) {
17
+ const path = new URL(page.url()).pathname.replace(/\/$/, "");
18
+ if (path === "/admin") {
19
+ const adminLanding = new AdminLandingPageModel(frame);
20
+ await adminLanding.oneAdminConsoleOption.waitFor({ state: "visible" });
21
+ await adminLanding.clickOneAdminConsole();
22
+ }
13
23
  const settings = new SelectSettingsPageModel(frame);
14
24
  await settings.container.waitFor({ state: "visible" });
15
25
  await settings.expandTreeItem("eFolder");
@@ -0,0 +1,22 @@
1
+ class AdminLandingPageModel {
2
+ constructor(frame) {
3
+ this.frame = frame;
4
+ }
5
+ frame;
6
+ get oneAdminConsoleOption() {
7
+ return this.frame.getByText("ONE ADMIN CONSOLE");
8
+ }
9
+ async clickOneAdminConsole() {
10
+ await this.oneAdminConsoleOption.click();
11
+ }
12
+ static async acceptCookiesIfShown(page) {
13
+ try {
14
+ await page.locator("#onetrust-group-container").waitFor({ state: "visible", timeout: 5e3 });
15
+ await page.locator("button", { hasText: "Accept All Cookies" }).click();
16
+ } catch {
17
+ }
18
+ }
19
+ }
20
+ export {
21
+ AdminLandingPageModel
22
+ };
@@ -1,6 +1,8 @@
1
+ import { AdminLandingPageModel } from "./AdminLandingPageModel.js";
1
2
  import { SelectSettingsPageModel } from "./SelectSettingsPageModel.js";
2
3
  import { ExportPageModel } from "./ExportPageModel.js";
3
4
  export {
5
+ AdminLandingPageModel,
4
6
  ExportPageModel,
5
7
  SelectSettingsPageModel
6
8
  };
@@ -1,11 +1,13 @@
1
1
  import type { EnvironmentParams } from '../types/config.js';
2
2
  import { MissingRequiredParamError } from './missingRequiredParamError.js';
3
+ import { UnknownEnvError } from './unknownEnvError.js';
3
4
  export interface CliEnvOpts {
4
- readonly baseUrl?: string;
5
+ readonly env?: string;
6
+ readonly envs?: Readonly<Record<string, string>>;
5
7
  readonly instanceId?: string;
6
8
  readonly userId?: string;
7
9
  }
8
- export { MissingRequiredParamError };
10
+ export { MissingRequiredParamError, UnknownEnvError };
9
11
  export declare class RequiredEnvParamsResolver {
10
12
  resolve(cliOpts: CliEnvOpts): EnvironmentParams;
11
13
  private resolveBaseUrl;
@@ -5,6 +5,7 @@ export interface ResolvedRunnerConfigPartial {
5
5
  readonly aiEnabled: boolean;
6
6
  readonly aiModel: string;
7
7
  readonly aiTemperature: number;
8
+ readonly envs: Readonly<Record<string, string>>;
8
9
  }
9
10
  export declare class RunnerConfigLoader {
10
11
  private readonly sources;
@@ -25,7 +25,9 @@ export declare const aiConfigFileSchema: z.ZodObject<{
25
25
  model?: string | undefined;
26
26
  temperature?: number | undefined;
27
27
  }>;
28
+ export declare const envsSchema: z.ZodRecord<z.ZodString, z.ZodString>;
28
29
  export declare const runnerConfigFileSchema: z.ZodObject<{
30
+ $schema: z.ZodOptional<z.ZodOptional<z.ZodString>>;
29
31
  runner: z.ZodOptional<z.ZodObject<{
30
32
  headless: z.ZodOptional<z.ZodBoolean>;
31
33
  outputDir: z.ZodOptional<z.ZodString>;
@@ -52,7 +54,9 @@ export declare const runnerConfigFileSchema: z.ZodObject<{
52
54
  model?: string | undefined;
53
55
  temperature?: number | undefined;
54
56
  }>>;
57
+ envs: z.ZodOptional<z.ZodRecord<z.ZodString, z.ZodString>>;
55
58
  }, "strict", z.ZodTypeAny, {
59
+ $schema?: string | undefined;
56
60
  runner?: {
57
61
  headless?: boolean | undefined;
58
62
  outputDir?: string | undefined;
@@ -63,7 +67,9 @@ export declare const runnerConfigFileSchema: z.ZodObject<{
63
67
  model?: string | undefined;
64
68
  temperature?: number | undefined;
65
69
  } | undefined;
70
+ envs?: Record<string, string> | undefined;
66
71
  }, {
72
+ $schema?: string | undefined;
67
73
  runner?: {
68
74
  headless?: boolean | undefined;
69
75
  outputDir?: string | undefined;
@@ -74,5 +80,6 @@ export declare const runnerConfigFileSchema: z.ZodObject<{
74
80
  model?: string | undefined;
75
81
  temperature?: number | undefined;
76
82
  } | undefined;
83
+ envs?: Record<string, string> | undefined;
77
84
  }>;
78
85
  export type RawRunnerConfigFile = z.infer<typeof runnerConfigFileSchema>;
@@ -0,0 +1,5 @@
1
+ export declare class UnknownEnvError extends Error {
2
+ readonly name: string;
3
+ readonly knownEnvs: readonly string[];
4
+ constructor(name: string, knownEnvs: readonly string[]);
5
+ }
@@ -14,5 +14,5 @@ export { RunnerConfigLoader } from './config/runnerConfigLoader.js';
14
14
  export { FileConfigSource } from './config/sources/fileConfigSource.js';
15
15
  export { EnvVarConfigSource } from './config/sources/envVarConfigSource.js';
16
16
  export { CliOverrideConfigSource } from './config/sources/cliOverrideConfigSource.js';
17
- export { RequiredEnvParamsResolver, MissingRequiredParamError, } from './config/requiredEnvParams.js';
17
+ export { RequiredEnvParamsResolver, MissingRequiredParamError, UnknownEnvError, } from './config/requiredEnvParams.js';
18
18
  export { buildProgram, defaultDeps } from './cli/index.js';
@@ -8,8 +8,9 @@ import type { RunnerConfig } from '../types/config.js';
8
8
  *
9
9
  * If the config carries no AI config, return the report's original markdown
10
10
  * untouched (no AI module loaded).
11
- * @param report
12
- * @param config
13
- * @param scenarioName
11
+ * @param {ComparisonReport} report - Heap-doctor comparison report to enhance.
12
+ * @param {RunnerConfig} config - Runner config; only `config.ai` is consulted.
13
+ * @param {string} scenarioName - Scenario name used in stderr warnings.
14
+ * @returns {Promise<string>} Markdown for the report, with or without AI section.
14
15
  */
15
16
  export declare function enhanceMarkdownIfConfigured(report: ComparisonReport, config: RunnerConfig, scenarioName: string): Promise<string>;
@@ -0,0 +1,8 @@
1
+ import type { Frame, Locator, Page } from '@playwright/test';
2
+ export declare class AdminLandingPageModel {
3
+ private readonly frame;
4
+ constructor(frame: Frame);
5
+ get oneAdminConsoleOption(): Locator;
6
+ clickOneAdminConsole(): Promise<void>;
7
+ static acceptCookiesIfShown(page: Page): Promise<void>;
8
+ }
@@ -1,2 +1,3 @@
1
+ export { AdminLandingPageModel } from './AdminLandingPageModel.js';
1
2
  export { SelectSettingsPageModel } from './SelectSettingsPageModel.js';
2
3
  export { ExportPageModel } from './ExportPageModel.js';
@@ -7,6 +7,7 @@ export interface MicroappLeakScenario {
7
7
  tags?: string[];
8
8
  microappSelector: string;
9
9
  url(): string;
10
+ setup?(page: Page, frame: Frame): Promise<void>;
10
11
  action(page: Page, frame: Frame): Promise<void>;
11
12
  back?(page: Page): Promise<void>;
12
13
  repeat?(): number;
@@ -9,5 +9,23 @@
9
9
  "enabled": false,
10
10
  "model": "Claude3.7",
11
11
  "temperature": 0.3
12
+ },
13
+ "envs": {
14
+ "LOCALHOST": "http://localhost:3000",
15
+ "D1": "https://encompass.d1.ice.com",
16
+ "D2": "https://encompass.d2.ice.com",
17
+ "Q1": "https://encompass.q1.ice.com",
18
+ "Q2": "https://encompass.q2.ice.com",
19
+ "Q3": "https://encompass.q3.ice.com",
20
+ "Q4": "https://encompass.q4.ice.com",
21
+ "Q5": "https://encompass.q5.ice.com",
22
+ "Q6": "https://encompass.q6.ice.com",
23
+ "Q7": "https://encompass.q7.ice.com",
24
+ "I1": "https://encompass.i1.ice.com",
25
+ "PL1": "https://encompass.pl1.ice.com",
26
+ "S1": "https://encompass.s1.ice.com",
27
+ "UAT1": "https://encompass.uat1.ice.com",
28
+ "PR": "https://encompass.pr.ice.com",
29
+ "PROD": "https://encompass.elliemae.io"
12
30
  }
13
31
  }
@@ -22,6 +22,11 @@
22
22
  "model": { "type": "string", "minLength": 1 },
23
23
  "temperature": { "type": "number", "minimum": 0, "maximum": 2 }
24
24
  }
25
+ },
26
+ "envs": {
27
+ "type": "object",
28
+ "description": "Map of environment name -> base URL. Selected at runtime via --env <name> or ENCW_ENV.",
29
+ "additionalProperties": { "type": "string", "minLength": 1 }
25
30
  }
26
31
  }
27
32
  }
@@ -12,12 +12,15 @@ import {
12
12
  import { EnvVarConfigSource } from '../../config/sources/envVarConfigSource.js';
13
13
  import { FileConfigSource } from '../../config/sources/fileConfigSource.js';
14
14
  import { RunnerConfigLoader } from '../../config/runnerConfigLoader.js';
15
- import { MissingRequiredParamError } from '../../config/requiredEnvParams.js';
15
+ import {
16
+ MissingRequiredParamError,
17
+ UnknownEnvError,
18
+ } from '../../config/requiredEnvParams.js';
16
19
 
17
20
  interface RunOptions extends CliRunnerOverrides {
18
21
  all?: boolean;
19
22
  tag?: string;
20
- baseUrl?: string;
23
+ env?: string;
21
24
  instanceId?: string;
22
25
  userId?: string;
23
26
  configFile?: string;
@@ -32,7 +35,10 @@ export class RunCommand implements CliCommand {
32
35
  )
33
36
  .option('--all', 'Run all registered scenarios')
34
37
  .option('--tag <tag>', 'Run all scenarios matching a tag')
35
- .option('--base-url <url>', 'Override base URL')
38
+ .option(
39
+ '--env <name>',
40
+ 'Select a named environment from leak-runner.config.json envs (also reads ENCW_ENV)',
41
+ )
36
42
  .option('--instance-id <id>', 'Override instance ID')
37
43
  .option('--user-id <id>', 'Override user ID')
38
44
  .option('--output-dir <dir>', 'Override output directory')
@@ -54,7 +60,10 @@ export class RunCommand implements CliCommand {
54
60
  await new JunitReporter().write(summary, config.runner.outputDir);
55
61
  process.exit(summary.failCount > 0 ? 1 : 0);
56
62
  } catch (err) {
57
- if (err instanceof MissingRequiredParamError) {
63
+ if (
64
+ err instanceof MissingRequiredParamError ||
65
+ err instanceof UnknownEnvError
66
+ ) {
58
67
  process.stderr.write(`Configuration error: ${err.message}\n`);
59
68
  process.exit(2);
60
69
  }
@@ -83,7 +92,8 @@ export class RunCommand implements CliCommand {
83
92
  const resolved = loader.resolveOptions();
84
93
 
85
94
  const env = deps.envParams.resolve({
86
- baseUrl: options.baseUrl,
95
+ env: options.env,
96
+ envs: resolved.envs,
87
97
  instanceId: options.instanceId,
88
98
  userId: options.userId,
89
99
  });
@@ -2,8 +2,8 @@ export class MissingRequiredParamError extends Error {
2
2
  constructor(public readonly missing: readonly string[]) {
3
3
  super(
4
4
  `Missing required parameter(s): ${missing.join(', ')}.\n` +
5
- `Provide via CLI flag (--base-url / --instance-id / --user-id) ` +
6
- `or env var (BASE_URL / ENCW_INSTANCE_ID / ENCW_USER_ID / ENCW_PASSWORD).`,
5
+ `Provide via CLI flag (--env / --instance-id / --user-id) ` +
6
+ `or env var (BASE_URL / ENCW_ENV / ENCW_INSTANCE_ID / ENCW_USER_ID / ENCW_PASSWORD).`,
7
7
  );
8
8
  this.name = 'MissingRequiredParamError';
9
9
  }
@@ -1,13 +1,15 @@
1
1
  import type { EnvironmentParams } from '../types/config.js';
2
2
  import { MissingRequiredParamError } from './missingRequiredParamError.js';
3
+ import { UnknownEnvError } from './unknownEnvError.js';
3
4
 
4
5
  export interface CliEnvOpts {
5
- readonly baseUrl?: string;
6
+ readonly env?: string;
7
+ readonly envs?: Readonly<Record<string, string>>;
6
8
  readonly instanceId?: string;
7
9
  readonly userId?: string;
8
10
  }
9
11
 
10
- export { MissingRequiredParamError };
12
+ export { MissingRequiredParamError, UnknownEnvError };
11
13
 
12
14
  export class RequiredEnvParamsResolver {
13
15
  resolve(cliOpts: CliEnvOpts): EnvironmentParams {
@@ -23,8 +25,17 @@ export class RequiredEnvParamsResolver {
23
25
  return params;
24
26
  }
25
27
 
28
+ // Precedence: BASE_URL env > --env / ENCW_ENV map lookup.
29
+ // Throws UnknownEnvError when an env name is supplied but not in the map,
30
+ // so a typo fails loudly instead of silently falling through to "missing baseUrl".
26
31
  private resolveBaseUrl(cliOpts: CliEnvOpts): string {
27
- return cliOpts.baseUrl || process.env.BASE_URL || '';
32
+ if (process.env.BASE_URL) return process.env.BASE_URL;
33
+ const envName = cliOpts.env || process.env.ENCW_ENV;
34
+ if (!envName) return '';
35
+ const envs = cliOpts.envs ?? {};
36
+ const fromMap = envs[envName];
37
+ if (!fromMap) throw new UnknownEnvError(envName, Object.keys(envs));
38
+ return fromMap;
28
39
  }
29
40
 
30
41
  private resolveInstanceId(cliOpts: CliEnvOpts): string {
@@ -8,6 +8,7 @@ export interface ResolvedRunnerConfigPartial {
8
8
  readonly aiEnabled: boolean;
9
9
  readonly aiModel: string;
10
10
  readonly aiTemperature: number;
11
+ readonly envs: Readonly<Record<string, string>>;
11
12
  }
12
13
 
13
14
  type MutableRunner = { headless: boolean; outputDir: string; topN: number };
@@ -18,6 +19,12 @@ type AiAccumulator = {
18
19
  aiTemperature: number;
19
20
  };
20
21
 
22
+ type Accumulator = {
23
+ runner: MutableRunner;
24
+ ai: AiAccumulator;
25
+ envs: Record<string, string>;
26
+ };
27
+
21
28
  function applyRunnerPayload(
22
29
  acc: MutableRunner,
23
30
  payload: NonNullable<RawRunnerConfigFile['runner']>,
@@ -40,16 +47,14 @@ function applyAiPayload(
40
47
  };
41
48
  }
42
49
 
43
- function applySource(
44
- acc: { runner: MutableRunner; ai: AiAccumulator },
45
- source: ConfigSource,
46
- ): { runner: MutableRunner; ai: AiAccumulator } {
50
+ function applySource(acc: Accumulator, source: ConfigSource): Accumulator {
47
51
  const payload = source.load();
48
52
  return {
49
53
  runner: payload.runner
50
54
  ? applyRunnerPayload(acc.runner, payload.runner)
51
55
  : acc.runner,
52
56
  ai: payload.ai ? applyAiPayload(acc.ai, payload.ai) : acc.ai,
57
+ envs: payload.envs ? { ...acc.envs, ...payload.envs } : acc.envs,
53
58
  };
54
59
  }
55
60
 
@@ -59,7 +64,7 @@ export class RunnerConfigLoader {
59
64
  resolveOptions(): ResolvedRunnerConfigPartial {
60
65
  const ordered = [...this.sources].sort((a, b) => a.priority - b.priority);
61
66
 
62
- const initial = {
67
+ const initial: Accumulator = {
63
68
  runner: {
64
69
  headless: BUILT_IN_DEFAULTS.runner.headless ?? true,
65
70
  outputDir: BUILT_IN_DEFAULTS.runner.outputDir ?? './leak-reports/',
@@ -70,6 +75,7 @@ export class RunnerConfigLoader {
70
75
  aiModel: BUILT_IN_DEFAULTS.ai.model ?? 'Claude3.7',
71
76
  aiTemperature: BUILT_IN_DEFAULTS.ai.temperature ?? 0.3,
72
77
  },
78
+ envs: {},
73
79
  };
74
80
 
75
81
  const resolved = ordered.reduce(applySource, initial);
@@ -79,6 +85,7 @@ export class RunnerConfigLoader {
79
85
  aiEnabled: resolved.ai.aiEnabled,
80
86
  aiModel: resolved.ai.aiModel,
81
87
  aiTemperature: resolved.ai.aiTemperature,
88
+ envs: resolved.envs,
82
89
  };
83
90
  }
84
91
  }
@@ -16,10 +16,14 @@ export const aiConfigFileSchema = z
16
16
  })
17
17
  .partial();
18
18
 
19
+ export const envsSchema = z.record(z.string().min(1));
20
+
19
21
  export const runnerConfigFileSchema = z
20
22
  .object({
23
+ $schema: z.string().optional(),
21
24
  runner: runnerOptionsSchema,
22
25
  ai: aiConfigFileSchema,
26
+ envs: envsSchema,
23
27
  })
24
28
  .partial()
25
29
  .strict();
@@ -46,4 +46,30 @@ describe('FileConfigSource', () => {
46
46
  const src = new FileConfigSource('/anything');
47
47
  expect(src.priority).toBe(1);
48
48
  });
49
+
50
+ it('parses an envs map of named environment URLs', () => {
51
+ const file = makeTempFile(
52
+ JSON.stringify({
53
+ envs: {
54
+ Q3: 'https://q3.elliemae.io',
55
+ Q4: 'https://q4.elliemae.io',
56
+ LOCALHOST: 'http://localhost:3000',
57
+ },
58
+ }),
59
+ );
60
+ const src = new FileConfigSource(file);
61
+ expect(src.load()).toEqual({
62
+ envs: {
63
+ Q3: 'https://q3.elliemae.io',
64
+ Q4: 'https://q4.elliemae.io',
65
+ LOCALHOST: 'http://localhost:3000',
66
+ },
67
+ });
68
+ });
69
+
70
+ it('rejects an envs map whose URL value is an empty string', () => {
71
+ const file = makeTempFile(JSON.stringify({ envs: { Q3: '' } }));
72
+ const src = new FileConfigSource(file);
73
+ expect(() => src.load()).toThrow(/Invalid config file/);
74
+ });
49
75
  });
@@ -1,5 +1,6 @@
1
1
  import { RequiredEnvParamsResolver } from '../requiredEnvParams.js';
2
2
  import { MissingRequiredParamError } from '../missingRequiredParamError.js';
3
+ import { UnknownEnvError } from '../unknownEnvError.js';
3
4
 
4
5
  describe('RequiredEnvParamsResolver', () => {
5
6
  const ORIGINAL_ENV = process.env;
@@ -7,6 +8,7 @@ describe('RequiredEnvParamsResolver', () => {
7
8
  beforeEach(() => {
8
9
  process.env = { ...ORIGINAL_ENV };
9
10
  delete process.env.BASE_URL;
11
+ delete process.env.ENCW_ENV;
10
12
  delete process.env.ENCW_INSTANCE_ID;
11
13
  delete process.env.ENCW_USER_ID;
12
14
  delete process.env.ENCW_PASSWORD;
@@ -32,7 +34,7 @@ describe('RequiredEnvParamsResolver', () => {
32
34
  'userId',
33
35
  'password (ENCW_PASSWORD)',
34
36
  ]);
35
- expect(err.message).toContain('--base-url');
37
+ expect(err.message).toContain('--env');
36
38
  expect(err.message).toContain('ENCW_PASSWORD');
37
39
  });
38
40
 
@@ -60,12 +62,11 @@ describe('RequiredEnvParamsResolver', () => {
60
62
  const resolver = new RequiredEnvParamsResolver();
61
63
  expect(
62
64
  resolver.resolve({
63
- baseUrl: 'https://q4.elliemae.io',
64
65
  instanceId: 'BE2',
65
66
  userId: 'user2',
66
67
  }),
67
68
  ).toEqual({
68
- baseUrl: 'https://q4.elliemae.io',
69
+ baseUrl: 'https://q3.elliemae.io',
69
70
  instanceId: 'BE2',
70
71
  userId: 'user2',
71
72
  password: 'pw',
@@ -76,8 +77,6 @@ describe('RequiredEnvParamsResolver', () => {
76
77
  process.env.BASE_URL = 'https://q3.elliemae.io';
77
78
  process.env.ENCW_INSTANCE_ID = 'BE1';
78
79
  process.env.ENCW_USER_ID = 'admin';
79
- // ENCW_PASSWORD intentionally unset
80
-
81
80
  const resolver = new RequiredEnvParamsResolver();
82
81
  expect(() => resolver.resolve({})).toThrow(MissingRequiredParamError);
83
82
  });
@@ -110,4 +109,80 @@ describe('RequiredEnvParamsResolver', () => {
110
109
  const resolver = new RequiredEnvParamsResolver();
111
110
  expect(() => resolver.resolve({})).toThrow(/Missing required parameter/);
112
111
  });
112
+
113
+ const ENVS = {
114
+ Q3: 'https://q3.elliemae.io',
115
+ Q4: 'https://q4.elliemae.io',
116
+ } as const;
117
+
118
+ function withSecrets(): void {
119
+ process.env.ENCW_INSTANCE_ID = 'BE1';
120
+ process.env.ENCW_USER_ID = 'admin';
121
+ process.env.ENCW_PASSWORD = 'pw';
122
+ }
123
+
124
+ it('resolves baseUrl from the envs map via --env', () => {
125
+ withSecrets();
126
+ const resolver = new RequiredEnvParamsResolver();
127
+ expect(resolver.resolve({ env: 'Q3', envs: ENVS }).baseUrl).toBe(
128
+ 'https://q3.elliemae.io',
129
+ );
130
+ });
131
+
132
+ it('resolves baseUrl from the envs map via ENCW_ENV', () => {
133
+ withSecrets();
134
+ process.env.ENCW_ENV = 'Q4';
135
+ const resolver = new RequiredEnvParamsResolver();
136
+ expect(resolver.resolve({ envs: ENVS }).baseUrl).toBe(
137
+ 'https://q4.elliemae.io',
138
+ );
139
+ });
140
+
141
+ it('--env beats ENCW_ENV when both are set', () => {
142
+ withSecrets();
143
+ process.env.ENCW_ENV = 'Q4';
144
+ const resolver = new RequiredEnvParamsResolver();
145
+ expect(resolver.resolve({ env: 'Q3', envs: ENVS }).baseUrl).toBe(
146
+ 'https://q3.elliemae.io',
147
+ );
148
+ });
149
+
150
+ it('BASE_URL env beats --env map lookup', () => {
151
+ withSecrets();
152
+ process.env.BASE_URL = 'https://from-env-var.example.com';
153
+ const resolver = new RequiredEnvParamsResolver();
154
+ expect(resolver.resolve({ env: 'Q3', envs: ENVS }).baseUrl).toBe(
155
+ 'https://from-env-var.example.com',
156
+ );
157
+ });
158
+
159
+ it('throws UnknownEnvError when --env names an undefined env', () => {
160
+ withSecrets();
161
+ const resolver = new RequiredEnvParamsResolver();
162
+ let caught: unknown;
163
+ try {
164
+ resolver.resolve({ env: 'Q99', envs: ENVS });
165
+ } catch (e) {
166
+ caught = e;
167
+ }
168
+ expect(caught).toBeInstanceOf(UnknownEnvError);
169
+ const err = caught as UnknownEnvError;
170
+ expect(err.message).toContain('Q99');
171
+ expect(err.message).toContain('Q3, Q4');
172
+ });
173
+
174
+ it('throws UnknownEnvError when ENCW_ENV is set but envs map is empty', () => {
175
+ withSecrets();
176
+ process.env.ENCW_ENV = 'Q3';
177
+ const resolver = new RequiredEnvParamsResolver();
178
+ expect(() => resolver.resolve({})).toThrow(UnknownEnvError);
179
+ });
180
+
181
+ it('falls back to MissingRequiredParamError when no baseUrl source is available', () => {
182
+ withSecrets();
183
+ const resolver = new RequiredEnvParamsResolver();
184
+ expect(() => resolver.resolve({ envs: ENVS })).toThrow(
185
+ MissingRequiredParamError,
186
+ );
187
+ });
113
188
  });
@@ -56,4 +56,34 @@ describe('RunnerConfigLoader', () => {
56
56
  const loader = new RunnerConfigLoader([file]);
57
57
  expect(loader.resolveOptions().aiEnabled).toBe(true);
58
58
  });
59
+
60
+ it('defaults envs to an empty map when no source contributes one', () => {
61
+ const loader = new RunnerConfigLoader([]);
62
+ expect(loader.resolveOptions().envs).toEqual({});
63
+ });
64
+
65
+ it('surfaces envs from a file source', () => {
66
+ const file = fakeSource(1, 'file', {
67
+ envs: { Q3: 'https://q3.elliemae.io', Q4: 'https://q4.elliemae.io' },
68
+ });
69
+ const loader = new RunnerConfigLoader([file]);
70
+ expect(loader.resolveOptions().envs).toEqual({
71
+ Q3: 'https://q3.elliemae.io',
72
+ Q4: 'https://q4.elliemae.io',
73
+ });
74
+ });
75
+
76
+ it('higher-priority sources override env URLs key-by-key', () => {
77
+ const file = fakeSource(1, 'file', {
78
+ envs: { Q3: 'https://q3.elliemae.io', Q4: 'https://q4.elliemae.io' },
79
+ });
80
+ const cli = fakeSource(3, 'cli', {
81
+ envs: { Q3: 'https://override.example.com' },
82
+ });
83
+ const loader = new RunnerConfigLoader([file, cli]);
84
+ expect(loader.resolveOptions().envs).toEqual({
85
+ Q3: 'https://override.example.com',
86
+ Q4: 'https://q4.elliemae.io',
87
+ });
88
+ });
59
89
  });
@@ -0,0 +1,13 @@
1
+ export class UnknownEnvError extends Error {
2
+ constructor(
3
+ public readonly name: string,
4
+ public readonly knownEnvs: readonly string[],
5
+ ) {
6
+ const knownList =
7
+ knownEnvs.length > 0 ? knownEnvs.join(', ') : '(none defined)';
8
+ super(
9
+ `Unknown env "${name}". Known envs in leak-runner.config.json: ${knownList}.`,
10
+ );
11
+ this.name = 'UnknownEnvError';
12
+ }
13
+ }
package/lib/index.ts CHANGED
@@ -33,5 +33,6 @@ export { CliOverrideConfigSource } from './config/sources/cliOverrideConfigSourc
33
33
  export {
34
34
  RequiredEnvParamsResolver,
35
35
  MissingRequiredParamError,
36
+ UnknownEnvError,
36
37
  } from './config/requiredEnvParams.js';
37
38
  export { buildProgram, defaultDeps } from './cli/index.js';
@@ -14,9 +14,10 @@ import type { RunnerConfig } from '../types/config.js';
14
14
  *
15
15
  * If the config carries no AI config, return the report's original markdown
16
16
  * untouched (no AI module loaded).
17
- * @param report
18
- * @param config
19
- * @param scenarioName
17
+ * @param {ComparisonReport} report - Heap-doctor comparison report to enhance.
18
+ * @param {RunnerConfig} config - Runner config; only `config.ai` is consulted.
19
+ * @param {string} scenarioName - Scenario name used in stderr warnings.
20
+ * @returns {Promise<string>} Markdown for the report, with or without AI section.
20
21
  */
21
22
  export async function enhanceMarkdownIfConfigured(
22
23
  report: ComparisonReport,