@sdods/core 0.2.1 → 0.2.2

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.
@@ -0,0 +1,262 @@
1
+ import { existsSync } from 'node:fs';
2
+ import { join, resolve as resolvePath } from 'node:path';
3
+ import { devices } from '@playwright/test';
4
+ import { cucumberReporter, defineBddConfig } from 'playwright-bdd';
5
+ import { pwProjectName, runFiles } from '@sdods/contracts';
6
+ import { coreStepsGlob } from '../steps/glob.js';
7
+ import { combineTagExpr, normalizeTagExpr } from './tags.js';
8
+ const DEVICE_FOR_BROWSER = {
9
+ chromium: 'Desktop Chrome',
10
+ firefox: 'Desktop Firefox',
11
+ webkit: 'Desktop Safari',
12
+ 'mobile-chrome': 'Pixel 7',
13
+ 'mobile-safari': 'iPhone 15',
14
+ };
15
+ export const DASHBOARD_REPORTER = '@sdods/core/reporters/dashboard';
16
+ /** Convenience for `playwright.config.ts`: read the selection from SDODS_* env vars. */
17
+ export function selectionFromEnv(env = process.env) {
18
+ const list = (v) => v
19
+ ? v
20
+ .split(',')
21
+ .map((s) => s.trim())
22
+ .filter(Boolean)
23
+ : undefined;
24
+ return {
25
+ project: env.SDODS_PROJECT || undefined,
26
+ env: env.SDODS_ENV || undefined,
27
+ layers: list(env.SDODS_LAYERS),
28
+ browsers: list(env.SDODS_BROWSERS),
29
+ tags: normalizeTagExpr(env.SDODS_TAGS),
30
+ runId: env.SDODS_RUN_ID || undefined,
31
+ lint: env.SDODS_LINT === '1',
32
+ allure: env.SDODS_ALLURE === '1',
33
+ reporters: list(env.SDODS_REPORTERS),
34
+ reporterMode: env.SDODS_REPORTER_MODE || 'default',
35
+ };
36
+ }
37
+ /** Names (and identity) of the Playwright projects a selection would produce, without side effects. */
38
+ export function listGeneratedProjects(registry, sel) {
39
+ const out = [];
40
+ for (const entry of sel.project ? [registry.entry(sel.project)] : registry.entriesList()) {
41
+ const p = entry.config;
42
+ const layers = (sel.layers?.length ? p.layers.filter((l) => sel.layers.includes(l)) : p.layers);
43
+ const browsers = (sel.browsers?.length
44
+ ? p.browsers.filter((b) => sel.browsers.includes(b))
45
+ : p.browsers);
46
+ for (const layer of layers) {
47
+ if (layer === 'api') {
48
+ out.push({ name: pwProjectName({ project: p.slug, layer }), project: p.slug, layer });
49
+ continue;
50
+ }
51
+ if (layer === 'recorded' && !existsSync(join(entry.root, 'recorded')))
52
+ continue;
53
+ for (const browser of browsers) {
54
+ out.push({
55
+ name: pwProjectName({ project: p.slug, layer, browser }),
56
+ project: p.slug,
57
+ layer,
58
+ browser,
59
+ });
60
+ }
61
+ }
62
+ }
63
+ return out;
64
+ }
65
+ /**
66
+ * Build the Playwright config for a selection of projects × layers × browsers.
67
+ * One `defineBddConfig` per project × layer; browsers reuse the generated testDir.
68
+ */
69
+ export function buildPlaywrightConfig(registry, sel = {}) {
70
+ const entries = sel.project ? [registry.entry(sel.project)] : registry.entriesList();
71
+ const projects = [];
72
+ let first;
73
+ let bddConfigs = 0;
74
+ const cliOverrides = sel.runId ? { runId: sel.runId } : undefined;
75
+ for (const entry of entries) {
76
+ const cfg = registry.resolve(entry.slug, sel.env, cliOverrides ? { ...parseEnvOverrides(), ...cliOverrides } : undefined);
77
+ first ??= cfg;
78
+ const p = cfg.project;
79
+ const layers = (sel.layers?.length ? p.layers.filter((l) => sel.layers.includes(l)) : p.layers);
80
+ const browsers = (sel.browsers?.length
81
+ ? p.browsers.filter((b) => sel.browsers.includes(b))
82
+ : p.browsers);
83
+ const envUse = {
84
+ locale: cfg.env.use.locale,
85
+ timezoneId: cfg.env.use.timezoneId,
86
+ geolocation: cfg.env.use.geolocation,
87
+ permissions: cfg.env.use.permissions,
88
+ colorScheme: cfg.env.use.colorScheme,
89
+ ignoreHTTPSErrors: cfg.env.use.ignoreHTTPSErrors,
90
+ extraHTTPHeaders: cfg.env.use.extraHTTPHeaders,
91
+ httpCredentials: cfg.env.use.httpCredentials,
92
+ };
93
+ for (const k of Object.keys(envUse))
94
+ if (envUse[k] === undefined)
95
+ delete envUse[k];
96
+ for (const layer of layers) {
97
+ if (layer === 'recorded') {
98
+ const recordedDir = join(p.root, 'recorded');
99
+ if (!existsSync(recordedDir))
100
+ continue;
101
+ for (const browser of browsers) {
102
+ projects.push({
103
+ name: pwProjectName({ project: p.slug, layer, browser }),
104
+ testDir: recordedDir,
105
+ testMatch: '**/*.spec.ts',
106
+ snapshotPathTemplate: join(p.root, 'features', '__screenshots__', '{projectName}', '{platform}', '{arg}{ext}'),
107
+ use: {
108
+ ...devices[DEVICE_FOR_BROWSER[browser]],
109
+ ...(p.channel && (browser === 'chromium' || browser === 'mobile-chrome')
110
+ ? { channel: p.channel }
111
+ : {}),
112
+ baseURL: cfg.env.ui.baseUrl,
113
+ testIdAttribute: p.testIdAttribute,
114
+ ...envUse,
115
+ sdods: { project: p.slug, layer, browser },
116
+ },
117
+ });
118
+ }
119
+ continue;
120
+ }
121
+ const testDir = defineBddConfig({
122
+ features: `${toPosix(p.root)}/features/**/*.feature`,
123
+ steps: [
124
+ coreStepsGlob(),
125
+ `${toPosix(p.root)}/steps/**/*.ts`,
126
+ `${toPosix(p.root)}/pages/**/*.ts`,
127
+ ],
128
+ // Each run generates into its own dir (cleaned by `sdods run`), lint/export into `.lint`,
129
+ // so concurrent runs and tooling never race on generated specs.
130
+ outputDir: `${toPosix(join(cfg.runtime.repoRoot, '.features-gen', sel.lint ? '.lint' : (sel.runId ?? 'adhoc'), p.slug, layer))}`,
131
+ featuresRoot: `${toPosix(p.root)}/features`,
132
+ // Explicit: scenarios that only use core steps cannot let bddgen guess the project test instance.
133
+ importTestFrom: `${toPosix(p.root)}/steps/fixtures.ts`,
134
+ disableWarnings: { importTestFrom: true },
135
+ tags: combineTagExpr(`@${layer}`, sel.tags),
136
+ examplesTitleFormat: 'Example #<_index_>',
137
+ missingSteps: sel.lint ? 'fail-on-gen' : 'fail-on-run',
138
+ aiFix: { promptAttachment: true },
139
+ quotes: 'single',
140
+ });
141
+ bddConfigs++;
142
+ if (layer === 'api') {
143
+ projects.push({
144
+ name: pwProjectName({ project: p.slug, layer }),
145
+ testDir,
146
+ use: { sdods: { project: p.slug, layer } },
147
+ });
148
+ continue;
149
+ }
150
+ for (const browser of browsers) {
151
+ projects.push({
152
+ name: pwProjectName({ project: p.slug, layer, browser }),
153
+ testDir,
154
+ // Baselines live with the project (generated specs are per-run and deleted):
155
+ // projects/<slug>/features/__screenshots__/<pw project>/<platform>/<name>.png
156
+ snapshotPathTemplate: join(p.root, 'features', '__screenshots__', '{projectName}', '{platform}', '{arg}{ext}'),
157
+ use: {
158
+ ...devices[DEVICE_FOR_BROWSER[browser]],
159
+ ...(p.channel && (browser === 'chromium' || browser === 'mobile-chrome')
160
+ ? { channel: p.channel }
161
+ : {}),
162
+ baseURL: cfg.env.ui.baseUrl,
163
+ testIdAttribute: p.testIdAttribute,
164
+ viewport: browser.startsWith('mobile') ? undefined : p.screenshots.viewport,
165
+ ...envUse,
166
+ sdods: { project: p.slug, layer, browser },
167
+ },
168
+ });
169
+ }
170
+ }
171
+ }
172
+ const runDir = first?.runtime.runDir ?? resolvePath('.sdods/runs/adhoc');
173
+ const timeouts = first?.project.timeouts ?? {
174
+ test: 60_000,
175
+ expect: 10_000,
176
+ action: 15_000,
177
+ navigation: 30_000,
178
+ api: 15_000,
179
+ };
180
+ const workers = first?.runtime.workers;
181
+ const retries = first?.runtime.retries ?? 0;
182
+ const reporterMode = sel.reporterMode ?? 'default';
183
+ const reporter = [];
184
+ // `--reporter <name[=outputFile]>` ADDS reporters (e.g. `blob` for sharded CI) to the SDODS
185
+ // defaults, so the NDJSON, dashboard and HTML report always exist. Path-less `blob`/`json`/`junit`
186
+ // land inside the run directory.
187
+ const extra = (sel.reporters ?? []).map((r) => {
188
+ const [name, file] = r.split('=');
189
+ if (file)
190
+ return [name, name === 'blob' ? { outputDir: file } : { outputFile: file }];
191
+ if (name === 'blob')
192
+ return ['blob', { outputDir: join(runDir, 'blob-report') }];
193
+ if (name === 'json')
194
+ return ['json', { outputFile: join(runDir, 'pw-results.extra.json') }];
195
+ if (name === 'junit')
196
+ return ['junit', { outputFile: join(runDir, runFiles.junit) }];
197
+ return [name];
198
+ });
199
+ {
200
+ reporter.push(reporterMode === 'server' ? ['line'] : reporterMode === 'quiet' ? ['dot'] : ['list']);
201
+ reporter.push(['html', { outputFolder: join(runDir, runFiles.pwReport), open: 'never' }]);
202
+ // The cucumber reporter throws when no defineBddConfig() ran (recorded-only selections).
203
+ if (bddConfigs > 0)
204
+ reporter.push(cucumberReporter('message', {
205
+ outputFile: join(runDir, runFiles.messages),
206
+ }));
207
+ reporter.push([DASHBOARD_REPORTER, { outputDir: join(runDir, runFiles.dashboard) }]);
208
+ if (projects.some((p) => String(p.name).includes('--recorded--'))) {
209
+ reporter.push(['json', { outputFile: join(runDir, runFiles.pwResults) }]);
210
+ }
211
+ if (first?.project.reports.junit || first?.runtime.ci)
212
+ reporter.push(['junit', { outputFile: join(runDir, runFiles.junit) }]);
213
+ if (first?.project.reports.cucumberHtml)
214
+ reporter.push(cucumberReporter('html', {
215
+ outputFile: join(runDir, 'cucumber-report.html'),
216
+ }));
217
+ if (sel.allure || first?.project.reports.allure)
218
+ reporter.push(['allure-playwright', { resultsDir: join(runDir, 'allure-results') }]);
219
+ }
220
+ for (const r of extra) {
221
+ if (reporter.some((d) => d[0] === r[0]))
222
+ continue; // already emitted by defaults (e.g. junit in CI)
223
+ reporter.push(r);
224
+ }
225
+ return {
226
+ timeout: timeouts.test,
227
+ expect: { timeout: timeouts.expect },
228
+ retries,
229
+ workers,
230
+ fullyParallel: true,
231
+ outputDir: join(runDir, runFiles.pwOutput),
232
+ reporter,
233
+ use: {
234
+ screenshot: 'off',
235
+ video: 'retain-on-failure',
236
+ trace: 'on-first-retry',
237
+ actionTimeout: timeouts.action,
238
+ navigationTimeout: timeouts.navigation,
239
+ },
240
+ projects,
241
+ metadata: {
242
+ sdodsRunId: first?.runtime.runId,
243
+ sdodsEnv: first?.env.name,
244
+ sdodsRunDir: runDir,
245
+ },
246
+ };
247
+ }
248
+ function parseEnvOverrides() {
249
+ const raw = process.env.SDODS_CLI_OVERRIDES;
250
+ if (!raw)
251
+ return {};
252
+ try {
253
+ return JSON.parse(raw);
254
+ }
255
+ catch {
256
+ return {};
257
+ }
258
+ }
259
+ function toPosix(p) {
260
+ return p.replace(/\\/g, '/');
261
+ }
262
+ //# sourceMappingURL=playwright.js.map
@@ -236,6 +236,9 @@ export function buildRunnerConfig(registry, sel = {}) {
236
236
  trace: 'on-first-retry',
237
237
  actionTimeout: timeouts.action,
238
238
  navigationTimeout: timeouts.navigation,
239
+ // Playwright's own `--headed` flag still wins on the CLI; this is what makes the documented
240
+ // SDODS_HEADED env var and the --headed CliOverride reach the browser at all.
241
+ headless: !first?.runtime.headed,
239
242
  },
240
243
  projects,
241
244
  metadata: {
@@ -39,7 +39,15 @@ export declare class AuthStateCache {
39
39
  private readState;
40
40
  /** Return a fresh storageState path for the user, capturing it with the strategy when needed. */
41
41
  ensure(user: PoolUserLike, auth: AuthStrategy, browser: Browser): Promise<string | undefined>;
42
- /** Atomic save: temp file + rename, sidecar written last. */
42
+ /**
43
+ * Atomic save: temp file + rename, sidecar written last.
44
+ *
45
+ * Written owner-only. This file is a live session for the application under test — cookies and
46
+ * localStorage, replayable as-is — and it used to land world-readable (0644 in a 0755
47
+ * directory), so any other account on the machine could lift it. Encryption is not the control
48
+ * here: the state has to be decryptable to be replayed, so a key would have to sit on the same
49
+ * disk. Restricting access is what actually protects it.
50
+ */
43
51
  save(user: PoolUserLike, state: Record<string, unknown>): string;
44
52
  /** Apply a cached state to a live context/page (step path: cookies + localStorage). */
45
53
  apply(args: {
@@ -92,15 +92,23 @@ export class AuthStateCache {
92
92
  return this.save(user, state);
93
93
  });
94
94
  }
95
- /** Atomic save: temp file + rename, sidecar written last. */
95
+ /**
96
+ * Atomic save: temp file + rename, sidecar written last.
97
+ *
98
+ * Written owner-only. This file is a live session for the application under test — cookies and
99
+ * localStorage, replayable as-is — and it used to land world-readable (0644 in a 0755
100
+ * directory), so any other account on the machine could lift it. Encryption is not the control
101
+ * here: the state has to be decryptable to be replayed, so a key would have to sit on the same
102
+ * disk. Restricting access is what actually protects it.
103
+ */
96
104
  save(user, state) {
97
- mkdirSync(this.dir, { recursive: true });
105
+ mkdirSync(this.dir, { recursive: true, mode: 0o700 });
98
106
  const file = this.fileFor(user);
99
107
  const tmp = `${file}.${process.pid}.${Math.random().toString(36).slice(2, 8)}.tmp`;
100
- writeFileSync(tmp, JSON.stringify(state, null, 2));
108
+ writeFileSync(tmp, JSON.stringify(state, null, 2), { mode: 0o600 });
101
109
  renameSync(tmp, file);
102
110
  const sideTmp = `${this.sidecarFor(user)}.${process.pid}.tmp`;
103
- writeFileSync(sideTmp, JSON.stringify({ capturedAt: new Date().toISOString(), user: user.username, role: user.role }, null, 2));
111
+ writeFileSync(sideTmp, JSON.stringify({ capturedAt: new Date().toISOString(), user: user.username, role: user.role }, null, 2), { mode: 0o600 });
104
112
  renameSync(sideTmp, this.sidecarFor(user));
105
113
  return file;
106
114
  }
@@ -147,7 +155,7 @@ export class AuthStateCache {
147
155
  * LOCK_STALE_MS is treated as abandoned by a crashed worker.
148
156
  */
149
157
  async withLock(user, fn) {
150
- mkdirSync(this.dir, { recursive: true });
158
+ mkdirSync(this.dir, { recursive: true, mode: 0o700 });
151
159
  const lock = this.lockFor(user);
152
160
  const deadline = Date.now() + LOCK_STALE_MS * 2;
153
161
  for (;;) {
@@ -37,7 +37,10 @@ export class ScenarioMeta {
37
37
  pickleLine: init.pickleLine,
38
38
  exampleIndex: null,
39
39
  tags,
40
- module: moduleForFeature(config.project, init.featureUri)?.name,
40
+ // `featureUri`, not `init.featureUri`: playwright-bdd hands us a repo-root-relative path,
41
+ // and moduleForFeature resolves its argument against project.root, so passing the raw
42
+ // value yields projects/<slug>/projects/<slug>/... and matches no module.
43
+ module: moduleForFeature(config.project, featureUri)?.name,
41
44
  process: process.env.SDODS_PROCESS || undefined,
42
45
  retry: testInfo.retry,
43
46
  workerIndex: testInfo.workerIndex,
@@ -63,6 +63,7 @@ export interface HarFile {
63
63
  entries: SdodsHarEntry[];
64
64
  };
65
65
  }
66
+ export declare const SECRET_HEADER: RegExp;
66
67
  export declare function normalizeUrl(url: string): string;
67
68
  export declare function bodyHash(body: unknown): string;
68
69
  export declare function harKey(method: string, url: string, body?: unknown): string;
@@ -3,7 +3,7 @@ import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
3
3
  import { dirname } from 'node:path';
4
4
  import { redact } from '../logger.js';
5
5
  import { VERSION } from '../version.js';
6
- const SECRET_HEADER = /^(authorization|cookie|set-cookie|x-api-key|proxy-authorization)$/i;
6
+ export const SECRET_HEADER = /^(authorization|cookie|set-cookie|x-api-key|proxy-authorization)$/i;
7
7
  const VOLATILE_QUERY = /^(_|t|ts|timestamp|nonce|cb|cache)$/i;
8
8
  export function normalizeUrl(url) {
9
9
  let u;
@@ -1,4 +1,5 @@
1
1
  export * from './paths.js';
2
2
  export * from './api-har.js';
3
3
  export * from './hooks.js';
4
+ export * from './scrub.js';
4
5
  //# sourceMappingURL=index.d.ts.map
package/dist/har/index.js CHANGED
@@ -1,4 +1,5 @@
1
1
  export * from './paths.js';
2
2
  export * from './api-har.js';
3
3
  export * from './hooks.js';
4
+ export * from './scrub.js';
4
5
  //# sourceMappingURL=index.js.map
@@ -0,0 +1,17 @@
1
+ /**
2
+ * Strip credentials out of a HAR recorded by the browser.
3
+ *
4
+ * The API layer builds its own HAR and redacts secret headers as it goes. The browser layer does
5
+ * not: Playwright's `routeFromHAR({ update: true })` writes the file itself, verbatim, including
6
+ * the `Cookie`, `Set-Cookie` and `Authorization` headers of the application under test. HARs are
7
+ * meant to be committed — the demo project's are in git — so recording against a real application
8
+ * while signed in wrote a live session into a tracked file.
9
+ *
10
+ * Values are replaced rather than the headers removed, so replay still matches on their presence.
11
+ */
12
+ export declare function scrubHar(har: unknown): {
13
+ changed: number;
14
+ };
15
+ /** Scrub a HAR file in place. Returns how many values were replaced, or -1 if it is not readable. */
16
+ export declare function scrubHarFile(file: string): number;
17
+ //# sourceMappingURL=scrub.d.ts.map
@@ -0,0 +1,60 @@
1
+ import { readFileSync, writeFileSync } from 'node:fs';
2
+ import { SECRET_HEADER } from './api-har.js';
3
+ /**
4
+ * Strip credentials out of a HAR recorded by the browser.
5
+ *
6
+ * The API layer builds its own HAR and redacts secret headers as it goes. The browser layer does
7
+ * not: Playwright's `routeFromHAR({ update: true })` writes the file itself, verbatim, including
8
+ * the `Cookie`, `Set-Cookie` and `Authorization` headers of the application under test. HARs are
9
+ * meant to be committed — the demo project's are in git — so recording against a real application
10
+ * while signed in wrote a live session into a tracked file.
11
+ *
12
+ * Values are replaced rather than the headers removed, so replay still matches on their presence.
13
+ */
14
+ export function scrubHar(har) {
15
+ let changed = 0;
16
+ const entries = har?.log?.entries;
17
+ if (!Array.isArray(entries))
18
+ return { changed };
19
+ for (const entry of entries) {
20
+ for (const side of ['request', 'response']) {
21
+ const headers = entry[side]?.headers;
22
+ if (!Array.isArray(headers))
23
+ continue;
24
+ for (const h of headers) {
25
+ if (h && typeof h.name === 'string' && SECRET_HEADER.test(h.name) && h.value !== '***') {
26
+ h.value = '***';
27
+ changed++;
28
+ }
29
+ }
30
+ }
31
+ // Playwright also records cookies as structured arrays alongside the headers.
32
+ for (const side of ['request', 'response']) {
33
+ const cookies = entry[side]?.cookies;
34
+ if (!Array.isArray(cookies))
35
+ continue;
36
+ for (const c of cookies) {
37
+ if (c && typeof c.value === 'string' && c.value !== '***') {
38
+ c.value = '***';
39
+ changed++;
40
+ }
41
+ }
42
+ }
43
+ }
44
+ return { changed };
45
+ }
46
+ /** Scrub a HAR file in place. Returns how many values were replaced, or -1 if it is not readable. */
47
+ export function scrubHarFile(file) {
48
+ let parsed;
49
+ try {
50
+ parsed = JSON.parse(readFileSync(file, 'utf8'));
51
+ }
52
+ catch {
53
+ return -1;
54
+ }
55
+ const { changed } = scrubHar(parsed);
56
+ if (changed > 0)
57
+ writeFileSync(file, `${JSON.stringify(parsed, null, 2)}\n`);
58
+ return changed;
59
+ }
60
+ //# sourceMappingURL=scrub.js.map
@@ -1,3 +1,4 @@
1
+ import { attachmentNames } from '@sdods/contracts';
1
2
  import { AfterScenario, AfterStep, BeforeScenario, BeforeStep } from '../fixtures/test.js';
2
3
  /**
3
4
  * Screenshot narrative hooks. Tag-filtered so API scenarios never instantiate a page.
@@ -32,5 +33,14 @@ AfterScenario({ name: 'sdods:finalize' }, async ({ scenario, apiContext, heal, $
32
33
  apiCalls: apiContext.history.length,
33
34
  heals: heal.events.length,
34
35
  });
36
+ // Publish the scenario identity to the report. Ingest already understands this attachment
37
+ // (parseAttachmentName -> kind 'meta') and prefers its `module` over the directory guess in
38
+ // moduleFromUri(), which is wrong whenever a module's `path` differs from its `name` -
39
+ // demo-shop's `posts-api` lives in features/api/, so the guess yields "api" and never joins
40
+ // to modules.name. Without this attach the meta branch only ever fired in tests.
41
+ await $testInfo.attach(attachmentNames.meta, {
42
+ body: JSON.stringify(scenario.data),
43
+ contentType: 'application/json',
44
+ });
35
45
  });
36
46
  //# sourceMappingURL=hooks.js.map
package/dist/version.d.ts CHANGED
@@ -1,2 +1,2 @@
1
- export declare const VERSION = "0.2.1";
1
+ export declare const VERSION = "0.2.2";
2
2
  //# sourceMappingURL=version.d.ts.map
package/dist/version.js CHANGED
@@ -1,2 +1,2 @@
1
- export const VERSION = '0.2.1';
1
+ export const VERSION = '0.2.2';
2
2
  //# sourceMappingURL=version.js.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@sdods/core",
3
- "version": "0.2.1",
3
+ "version": "0.2.2",
4
4
  "description": "SDODS runtime: configuration, project registry, fixtures, step libraries, data providers, screenshot narratives and self-healing locators.",
5
5
  "license": "Apache-2.0",
6
6
  "author": "SDODS <admin@sdods.com>",
@@ -42,7 +42,7 @@
42
42
  "playwright-bdd": ">=9"
43
43
  },
44
44
  "dependencies": {
45
- "@sdods/contracts": "0.2.1",
45
+ "@sdods/contracts": "0.2.2",
46
46
  "@cucumber/gherkin": "^42.0.1",
47
47
  "@cucumber/messages": "^34.2.1",
48
48
  "@faker-js/faker": "^10.6.0",
@@ -59,7 +59,7 @@
59
59
  "ts-morph": "^28.0.0",
60
60
  "yaml": "^2.9.0",
61
61
  "zod": "^4.5.4",
62
- "@sdods/db": "0.2.1"
62
+ "@sdods/db": "0.2.2"
63
63
  },
64
64
  "homepage": "https://sdods.com",
65
65
  "bugs": {