@sdods/core 0.17.0 → 0.19.0

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.
@@ -179,7 +179,9 @@ export class ApiClient {
179
179
  const reqFile = scenario.file(scenarioFiles.apiJson(stepIndex, n, 'request'));
180
180
  const resFile = scenario.file(scenarioFiles.apiJson(stepIndex, n, 'response'));
181
181
  writeFileSync(reqFile, JSON.stringify(redact(snap.request), null, 2));
182
- writeFileSync(resFile, JSON.stringify({ ...snap.response, rawBody: undefined }, null, 2));
182
+ // Response bodies carry login tokens and session ids, and run_get_scenario hands this file to
183
+ // agents, so it is redacted by key name like the request.
184
+ writeFileSync(resFile, JSON.stringify(redact({ ...snap.response, rawBody: undefined }), null, 2));
183
185
  void testInfo.attach(attachmentNames.api(stepIndex, n, 'request'), {
184
186
  path: reqFile,
185
187
  contentType: 'application/json',
@@ -16,7 +16,13 @@ export const FRAMEWORK_DEFAULTS = {
16
16
  viewport: { width: 1280, height: 720 },
17
17
  onlyOnFailure: false,
18
18
  },
19
- heal: { enabled: true, primaryTimeoutMs: 3000, probeTimeoutMs: 1500, minScore: 0.6 },
19
+ heal: {
20
+ enabled: true,
21
+ primaryTimeoutMs: 3000,
22
+ probeTimeoutMs: 1500,
23
+ minScore: 0.6,
24
+ ciMode: 'warn',
25
+ },
20
26
  timeouts: { test: 60_000, expect: 10_000, action: 15_000, navigation: 30_000, api: 15_000 },
21
27
  retries: { ci: 2, local: 0, byTag: {} },
22
28
  };
@@ -2,7 +2,7 @@ import { existsSync, readFileSync } from 'node:fs';
2
2
  import { basename, join, resolve as resolvePath } from 'node:path';
3
3
  import { parse as parseYaml } from 'yaml';
4
4
  import { ZodError } from 'zod';
5
- import { EnvConfigSchema, ProjectConfigSchema, newRunId, } from '@sdods/contracts';
5
+ import { EnvConfigSchema, ProjectConfigSchema, isRunId, newRunId, } from '@sdods/contracts';
6
6
  import { SdodsConfigError } from '../errors.js';
7
7
  import { DEFAULT_ARTIFACTS_DIR, ENV_TO_CONFIG_PATH, FRAMEWORK_DEFAULTS, PROJECT_FILE, } from './defaults.js';
8
8
  import { loadDotEnvLayer } from './env-files.js';
@@ -146,6 +146,11 @@ export function resolveConfig(opts) {
146
146
  // runtime finalisation
147
147
  const rt = interpolated.runtime;
148
148
  rt.runId = rt.runId || newRunId();
149
+ // runDir is `<artifactsDir>/<runId>`; a `..` id would point it (and its cleanup) elsewhere.
150
+ if (!isRunId(rt.runId))
151
+ throw new SdodsConfigError(`Invalid run id "${rt.runId}".`, {
152
+ hint: 'Use letters, digits, ".", "_" or "-", not starting with ".".',
153
+ });
149
154
  rt.retries = rt.retries ?? (rt.ci ? projectMerged.retries.ci : projectMerged.retries.local);
150
155
  rt.harMode = rt.harMode ?? 'off';
151
156
  rt.artifactsDir = resolvePath(opts.rootDir, rt.artifactsDir || DEFAULT_ARTIFACTS_DIR);
@@ -1,6 +1,11 @@
1
1
  import { type PlaywrightTestConfig } from '@playwright/test';
2
2
  import { type BrowserName, type Layer, type ProcessConfig, type ProjectConfig, type SetupConfig } from '@sdods/contracts';
3
3
  import type { ProjectRegistry } from './registry.js';
4
+ /**
5
+ * allure-playwright, resolved from @sdods/core rather than from the generated runner config: with
6
+ * bun's isolated linker a workspace or a global install does not see core's dependencies.
7
+ */
8
+ export declare function allureReporterPath(): string;
4
9
  export interface RunnerSelection {
5
10
  project?: string;
6
11
  env?: string;
@@ -1,10 +1,23 @@
1
1
  import { existsSync } from 'node:fs';
2
+ import { createRequire } from 'node:module';
2
3
  import { join, resolve as resolvePath } from 'node:path';
3
4
  import { devices } from '@playwright/test';
4
5
  import { cucumberReporter, defineBddConfig } from 'playwright-bdd';
5
6
  import { EVIDENCE_DEFAULTS, RUNNER_SETUP_PHASE, runnerProjectName, runFiles, } from '@sdods/contracts';
6
7
  import { coreStepsPatterns } from '../steps/glob.js';
7
8
  import { combineTagExpr, normalizeTagExpr } from './tags.js';
9
+ /**
10
+ * allure-playwright, resolved from @sdods/core rather than from the generated runner config: with
11
+ * bun's isolated linker a workspace or a global install does not see core's dependencies.
12
+ */
13
+ export function allureReporterPath() {
14
+ try {
15
+ return createRequire(import.meta.url).resolve('allure-playwright');
16
+ }
17
+ catch {
18
+ return 'allure-playwright';
19
+ }
20
+ }
8
21
  const DEVICE_FOR_BROWSER = {
9
22
  chromium: 'Desktop Chrome',
10
23
  edge: 'Desktop Edge',
@@ -259,6 +272,8 @@ export function buildRunnerConfig(registry, sel = {}) {
259
272
  // land inside the run directory.
260
273
  const extra = (sel.reporters ?? []).map((r) => {
261
274
  const [name, file] = r.split('=');
275
+ if (file && (name === 'allure' || name === 'allure-playwright'))
276
+ return [allureReporterPath(), { resultsDir: file }];
262
277
  if (file)
263
278
  return [name, name === 'blob' ? { outputDir: file } : { outputFile: file }];
264
279
  if (name === 'blob')
@@ -267,6 +282,8 @@ export function buildRunnerConfig(registry, sel = {}) {
267
282
  return ['json', { outputFile: join(runDir, 'runner-results.extra.json') }];
268
283
  if (name === 'junit')
269
284
  return ['junit', { outputFile: join(runDir, runFiles.junit) }];
285
+ if (name === 'allure' || name === 'allure-playwright')
286
+ return [allureReporterPath(), { resultsDir: file ?? join(runDir, 'allure-results') }];
270
287
  return [name];
271
288
  });
272
289
  {
@@ -294,7 +311,7 @@ export function buildRunnerConfig(registry, sel = {}) {
294
311
  outputFile: join(runDir, 'cucumber-report.html'),
295
312
  }));
296
313
  if (sel.allure || first?.project.reports.allure)
297
- reporter.push(['allure-playwright', { resultsDir: join(runDir, 'allure-results') }]);
314
+ reporter.push([allureReporterPath(), { resultsDir: join(runDir, 'allure-results') }]);
298
315
  }
299
316
  for (const r of extra) {
300
317
  if (reporter.some((d) => d[0] === r[0]))
package/dist/errors.d.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  /** Structured errors: every CLI/MCP/server surface renders `{ code, message, hint, docsUrl }`. */
2
- export type SdodsErrorCode = 'CONFIG_INVALID' | 'CONFIG_NOT_FOUND' | 'CONFIG_SECRET_LITERAL' | 'CONFIG_UNRESOLVED_VAR' | 'PROJECT_NOT_FOUND' | 'ENV_NOT_FOUND' | 'DATASET_NOT_FOUND' | 'DATASET_ROW_NOT_FOUND' | 'USER_POOL_EXHAUSTED' | 'USER_POOL_TOO_SMALL' | 'HEAL_FAILED' | 'LINT_FAILED' | 'RUN_FAILED' | 'GATE_FAILED' | 'HAR_MISS' | 'DB_REQUIRED' | 'MAIL_REQUIRED' | 'MAIL_NOT_RECEIVED' | 'AUTH_FAILED' | 'NOT_SUPPORTED' | 'INTERNAL';
2
+ export type SdodsErrorCode = 'CONFIG_INVALID' | 'CONFIG_NOT_FOUND' | 'CONFIG_SECRET_LITERAL' | 'CONFIG_UNRESOLVED_VAR' | 'PROJECT_NOT_FOUND' | 'ENV_NOT_FOUND' | 'DATASET_NOT_FOUND' | 'DATASET_ROW_NOT_FOUND' | 'USER_POOL_EXHAUSTED' | 'USER_POOL_TOO_SMALL' | 'HEAL_FAILED' | 'HEAL_BLOCKED_IN_CI' | 'LINT_FAILED' | 'RUN_FAILED' | 'GATE_FAILED' | 'HAR_MISS' | 'DB_REQUIRED' | 'MAIL_REQUIRED' | 'MAIL_NOT_RECEIVED' | 'AUTH_FAILED' | 'NOT_SUPPORTED' | 'INTERNAL';
3
3
  export declare const DOCS_BASE_URL = "https://docs.sdods.com";
4
4
  export interface SdodsErrorOptions {
5
5
  hint?: string;
@@ -257,6 +257,7 @@ export const test = base.extend({
257
257
  heal: async ({ config, healHistory, scenario, $bddContext }, use, testInfo) => {
258
258
  await use(new Healer({
259
259
  config: config.project.heal,
260
+ ci: config.runtime.ci,
260
261
  history: healHistory,
261
262
  scenario,
262
263
  testInfo,
@@ -5,12 +5,21 @@ import type { HealAction, HealContext } from './types.js';
5
5
  import type { HealHistory } from './history.js';
6
6
  export interface HealerDeps {
7
7
  config: HealConfig;
8
+ /** True under CI; `config.ciMode` then decides whether healing warns, fails or is off. */
9
+ ci?: boolean;
8
10
  history?: HealHistory;
9
11
  scenario?: ScenarioMeta;
10
12
  testInfo?: TestInfo;
11
13
  stepIndex?: () => number;
12
14
  runId?: string;
13
15
  }
16
+ /** What the healer does for this run: heal quietly (local), or the CI mode. */
17
+ export type HealMode = 'heal' | 'warn' | 'fail' | 'off';
18
+ /**
19
+ * Local runs heal as they always have. Under CI the configured `ciMode` applies (default `warn`),
20
+ * because a heal in CI means the application changed under the suite and must be visible.
21
+ */
22
+ export declare function effectiveHealMode(config: Pick<HealConfig, 'enabled' | 'ciMode'>, ci: boolean): HealMode;
14
23
  /** Locator wrapper: every action goes through the healer. */
15
24
  export declare class HealedLocator {
16
25
  private readonly healer;
@@ -4,6 +4,17 @@ import { attachmentNames, scenarioFiles } from '@sdods/contracts';
4
4
  import { Logger } from '../logger.js';
5
5
  import { SdodsError } from '../errors.js';
6
6
  import { buildCandidates, disambiguationCandidates, rolesFor } from './strategies.js';
7
+ /**
8
+ * Local runs heal as they always have. Under CI the configured `ciMode` applies (default `warn`),
9
+ * because a heal in CI means the application changed under the suite and must be visible.
10
+ */
11
+ export function effectiveHealMode(config, ci) {
12
+ if (!config.enabled)
13
+ return 'off';
14
+ if (!ci)
15
+ return 'heal';
16
+ return config.ciMode ?? 'warn';
17
+ }
7
18
  /** Locator wrapper: every action goes through the healer. */
8
19
  export class HealedLocator {
9
20
  healer;
@@ -109,7 +120,8 @@ export class Healer {
109
120
  */
110
121
  async resolve(primary, ctx, action) {
111
122
  const cfg = this.deps.config;
112
- const healable = cfg.enabled && cfg.actions.includes(action);
123
+ const mode = effectiveHealMode(cfg, Boolean(this.deps.ci));
124
+ const healable = mode !== 'off' && cfg.actions.includes(action);
113
125
  try {
114
126
  await primary.first().waitFor({ state: 'visible', timeout: cfg.primaryTimeoutMs });
115
127
  }
@@ -162,6 +174,9 @@ export class Healer {
162
174
  durationMs: Date.now() - t0,
163
175
  at: new Date().toISOString(),
164
176
  };
177
+ const blocked = Boolean(best) && effectiveHealMode(cfg, Boolean(this.deps.ci)) === 'fail';
178
+ if (blocked)
179
+ event.blockedInCi = true;
165
180
  this.record(event);
166
181
  const probed = ranked.map((p) => `${p.strategy}=${p.score.toFixed(2)}`).join(', ') || 'none';
167
182
  if (!best) {
@@ -175,6 +190,13 @@ export class Healer {
175
190
  cause: why.cause,
176
191
  });
177
192
  }
193
+ if (blocked) {
194
+ throw new SdodsError('HEAL_BLOCKED_IN_CI', `"${ctx.description}" (${String(primary)}) no longer matches, and heal.ciMode is "fail", so the step fails instead of healing to ${best.selector}.`, {
195
+ hint: `Update the page object to ${best.selector} (found via ${best.strategy}, score ${best.score.toFixed(2)}), or set heal.ciMode to "warn".`,
196
+ details: { candidates: event.candidates },
197
+ cause: why.cause,
198
+ });
199
+ }
178
200
  this.log.warn(`healed "${ctx.description}": ${String(primary)}${ambiguous ? ` (matched ${why.matched})` : ''} → ${best.selector} via ${best.strategy} (score ${best.score.toFixed(2)}, ${event.durationMs} ms). Update the page object.`);
179
201
  return best.locator.first();
180
202
  }
@@ -13,4 +13,21 @@ export interface HealReportRow {
13
13
  /** Recursively collect heal.jsonl files under a run dir (or all runs). */
14
14
  export declare function collectHealEvents(root: string): HealEvent[];
15
15
  export declare function summarizeHealEvents(events: HealEvent[]): HealReportRow[];
16
+ /** What a CI run prints about healing: GitHub annotations and a step-summary section. */
17
+ export interface CiHealSummary {
18
+ healed: number;
19
+ blocked: number;
20
+ /** `::warning` / `::error` workflow commands, one per distinct locator. */
21
+ annotations: string[];
22
+ /** Markdown for `$GITHUB_STEP_SUMMARY`. */
23
+ markdown: string;
24
+ /** One plain line for any CI. */
25
+ line: string;
26
+ }
27
+ /**
28
+ * Healing in CI must never hide a regression, so every heal in a CI run is surfaced: a heal that
29
+ * passed (`ciMode: warn`) becomes a warning, a heal that was refused (`ciMode: fail`) an error.
30
+ * Undefined when nothing healed.
31
+ */
32
+ export declare function ciHealSummary(events: HealEvent[]): CiHealSummary | undefined;
16
33
  //# sourceMappingURL=report.d.ts.map
@@ -61,4 +61,41 @@ export function summarizeHealEvents(events) {
61
61
  }
62
62
  return [...byKey.values()].sort((a, b) => b.occurrences - a.occurrences);
63
63
  }
64
+ const escapeCommand = (s) => s.replace(/%/g, '%25').replace(/\r/g, '%0D').replace(/\n/g, '%0A');
65
+ /**
66
+ * Healing in CI must never hide a regression, so every heal in a CI run is surfaced: a heal that
67
+ * passed (`ciMode: warn`) becomes a warning, a heal that was refused (`ciMode: fail`) an error.
68
+ * Undefined when nothing healed.
69
+ */
70
+ export function ciHealSummary(events) {
71
+ const found = events.filter((e) => e.succeeded);
72
+ if (found.length === 0)
73
+ return undefined;
74
+ const blocked = found.filter((e) => e.blockedInCi).length;
75
+ const healed = found.length - blocked;
76
+ const rows = summarizeHealEvents(found);
77
+ const annotations = rows.map((r) => {
78
+ const level = found.some((e) => e.blockedInCi &&
79
+ e.description === r.description &&
80
+ e.originalSelector === r.originalSelector)
81
+ ? 'error'
82
+ : 'warning';
83
+ const text = `"${r.description}": ${r.originalSelector} no longer matches; ${level === 'error' ? 'would heal' : 'healed'} to ${r.suggestedSelector ?? '?'} (${r.occurrences}x in ${r.scenarios.length} scenario(s)). Update the page object.`;
84
+ return `::${level} title=SDODS self-healing::${escapeCommand(text)}`;
85
+ });
86
+ const line = `self-healing: ${healed} locator heal(s) passed with a warning` +
87
+ (blocked ? `, ${blocked} refused (heal.ciMode=fail)` : '') +
88
+ '. The application changed under the suite; update the page objects.';
89
+ const markdown = [
90
+ '### SDODS self-healing',
91
+ '',
92
+ line,
93
+ '',
94
+ '| Locator | Original | Healed to | Times |',
95
+ '|---|---|---|---|',
96
+ ...rows.map((r) => `| ${r.description} | \`${r.originalSelector}\` | \`${r.suggestedSelector ?? '?'}\` | ${r.occurrences} |`),
97
+ '',
98
+ ].join('\n');
99
+ return { healed, blocked, annotations, markdown, line };
100
+ }
64
101
  //# sourceMappingURL=report.js.map
@@ -0,0 +1,74 @@
1
+ export type ImportLayer = 'ui' | 'api' | 'hybrid';
2
+ export interface SourceStep {
3
+ keyword: string;
4
+ /** A Cucumber expression, or a regular expression's source when `regex` is true. */
5
+ expression: string;
6
+ regex: boolean;
7
+ file: string;
8
+ line: number;
9
+ /** Raw source of the definition, for the stub's comment. */
10
+ body: string;
11
+ kind: 'ui' | 'api' | 'unknown';
12
+ }
13
+ export interface ImportedFeature {
14
+ /** Path inside the project, e.g. `features/checkout/cart.feature`. */
15
+ path: string;
16
+ content: string;
17
+ scenarios: number;
18
+ }
19
+ export interface ScenarioMapping {
20
+ feature: string;
21
+ scenario: string;
22
+ layer: ImportLayer;
23
+ layerInferred: boolean;
24
+ suite: string;
25
+ }
26
+ export interface CucumberImportPlan {
27
+ slug: string;
28
+ sourceDir: string;
29
+ features: ImportedFeature[];
30
+ layers: ImportLayer[];
31
+ extraTags: string[];
32
+ /** `steps/imported.steps.ts`, or undefined when every step is covered by the core library. */
33
+ stepsFile?: string;
34
+ mappings: ScenarioMapping[];
35
+ steps: {
36
+ total: number;
37
+ core: string[];
38
+ stubbed: string[];
39
+ undefined: string[];
40
+ };
41
+ manual: Array<{
42
+ what: string;
43
+ file: string;
44
+ line: number;
45
+ }>;
46
+ warnings: string[];
47
+ report: string;
48
+ }
49
+ /** What a step definition's body touches, by the libraries it names. */
50
+ export declare function classifyStepBody(body: string): SourceStep['kind'];
51
+ /**
52
+ * Step definitions in a Cucumber-JS source file: `Given('…', fn)`, `When(/…/, fn)`, `Then("…")`,
53
+ * and `defineStep`. Read as text; the body is everything up to the next definition.
54
+ */
55
+ export declare function extractStepDefinitions(file: string, text: string): SourceStep[];
56
+ /** Patterns of the SDODS core step library, read from its source like the source suite's. */
57
+ export declare function coreStepExpressions(dir?: string): string[];
58
+ /** Literal string values from a Cucumber config file, without running it. */
59
+ export declare function readCucumberConfig(text: string): {
60
+ paths: string[];
61
+ requires: string[];
62
+ tags?: string;
63
+ };
64
+ export interface PlanOptions {
65
+ slug: string;
66
+ /** Module folder under `features/`; default `imported`. */
67
+ module?: string;
68
+ /** Override the core step expressions (tests). */
69
+ coreExpressions?: string[];
70
+ }
71
+ export declare function planCucumberImport(sourceDir: string, opts: PlanOptions): CucumberImportPlan;
72
+ /** Parameters a step expression passes: `{…}` in a Cucumber expression, capture groups in a regex. */
73
+ export declare function argumentCount(step: Pick<SourceStep, 'expression' | 'regex'>): number;
74
+ //# sourceMappingURL=cucumber.d.ts.map