@sdods/core 0.7.2 → 0.8.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.
Files changed (53) hide show
  1. package/dist/.tsbuildinfo +1 -1
  2. package/dist/analyze/index.d.ts +2 -0
  3. package/dist/analyze/index.js +2 -0
  4. package/dist/analyze/traceability-render.d.ts +5 -0
  5. package/dist/analyze/traceability-render.js +254 -0
  6. package/dist/analyze/traceability.d.ts +87 -0
  7. package/dist/analyze/traceability.js +559 -0
  8. package/dist/api/json-path.d.ts +11 -1
  9. package/dist/api/json-path.js +23 -1
  10. package/dist/config/emulation.d.ts +74 -0
  11. package/dist/config/emulation.js +154 -0
  12. package/dist/config/index.d.ts +1 -0
  13. package/dist/config/index.js +1 -0
  14. package/dist/config/runner.js +4 -1
  15. package/dist/config/tags.d.ts +1 -1
  16. package/dist/config/tags.js +7 -0
  17. package/dist/errors.d.ts +1 -1
  18. package/dist/fixtures/api-context.d.ts +19 -1
  19. package/dist/fixtures/api-context.js +28 -1
  20. package/dist/fixtures/test.js +36 -0
  21. package/dist/fixtures/types.d.ts +3 -0
  22. package/dist/index.d.ts +3 -0
  23. package/dist/index.js +3 -0
  24. package/dist/lint/index.js +86 -0
  25. package/dist/load/index.d.ts +4 -0
  26. package/dist/load/index.js +4 -0
  27. package/dist/load/profile.d.ts +22 -0
  28. package/dist/load/profile.js +109 -0
  29. package/dist/load/run.d.ts +85 -0
  30. package/dist/load/run.js +255 -0
  31. package/dist/load/script.d.ts +27 -0
  32. package/dist/load/script.js +188 -0
  33. package/dist/mail/extract.d.ts +38 -0
  34. package/dist/mail/extract.js +106 -0
  35. package/dist/mail/index.d.ts +8 -0
  36. package/dist/mail/index.js +11 -0
  37. package/dist/mail/mailpit.d.ts +26 -0
  38. package/dist/mail/mailpit.js +124 -0
  39. package/dist/mail/types.d.ts +38 -0
  40. package/dist/mail/types.js +2 -0
  41. package/dist/matrix/index.d.ts +104 -0
  42. package/dist/matrix/index.js +486 -0
  43. package/dist/steps/api.steps.js +5 -7
  44. package/dist/steps/browser.steps.d.ts +12 -0
  45. package/dist/steps/browser.steps.js +154 -3
  46. package/dist/steps/canvas.steps.d.ts +9 -0
  47. package/dist/steps/canvas.steps.js +331 -0
  48. package/dist/steps/email.steps.d.ts +2 -0
  49. package/dist/steps/email.steps.js +182 -0
  50. package/dist/steps/hybrid.steps.js +2 -2
  51. package/dist/steps/index.d.ts +2 -0
  52. package/dist/steps/index.js +2 -0
  53. package/package.json +4 -3
@@ -0,0 +1,74 @@
1
+ /**
2
+ * Per-scenario browser emulation from tags: `@locale:<bcp47>`, `@timezone:<IANA>`,
3
+ * `@theme:<light|dark|no-preference>`, `@viewport:<W>x<H>` and `@device:<name>`.
4
+ *
5
+ * Six locales × dark/light used to mean twelve env yamls, because the only place a browser
6
+ * context's locale and colour scheme could be set was the env's `use:` block. The tags move that
7
+ * axis onto the scenario (or a Feature, or a tagged `Examples:` block), so one env file covers
8
+ * every combination.
9
+ *
10
+ * Locale and timezone are fixed when Playwright creates the browser context and cannot be
11
+ * changed afterwards, which is why these are tags resolved by fixtures before the context opens,
12
+ * rather than steps. Precedence, highest first: the tag, then the env's `use:`, then Playwright's
13
+ * default. `@viewport:` wins over the viewport of `@device:`.
14
+ *
15
+ * Kept pure (no fixtures, no browser) so the precedence and the validation that lint shares with
16
+ * the runtime are testable on their own — the `scenarioSkipReason` precedent.
17
+ */
18
+ export declare const EMULATION_TAGS: readonly ["locale", "timezone", "theme", "viewport", "device"];
19
+ export type EmulationTag = (typeof EMULATION_TAGS)[number];
20
+ export declare const COLOR_SCHEMES: readonly ["light", "dark", "no-preference"];
21
+ export type ColorScheme = (typeof COLOR_SCHEMES)[number];
22
+ export interface ViewportSize {
23
+ width: number;
24
+ height: number;
25
+ }
26
+ export interface EmulatedDevice {
27
+ /** The Playwright device descriptor name, e.g. `iPhone 15`. */
28
+ name: string;
29
+ userAgent: string;
30
+ viewport: ViewportSize;
31
+ deviceScaleFactor: number;
32
+ isMobile: boolean;
33
+ hasTouch: boolean;
34
+ }
35
+ export interface Emulation {
36
+ locale?: string;
37
+ timezoneId?: string;
38
+ colorScheme?: ColorScheme;
39
+ viewport?: ViewportSize;
40
+ device?: EmulatedDevice;
41
+ }
42
+ /** `320x640` → `{ width: 320, height: 640 }`, or undefined when it is not a usable size. */
43
+ export declare function parseViewport(value: string): ViewportSize | undefined;
44
+ /**
45
+ * A tag value cannot contain a space, and every Playwright device name does (`iPhone 15`,
46
+ * `Galaxy S9+`). Names match case-insensitively with any run of spaces, hyphens or underscores
47
+ * treated as one separator, so `@device:iPhone-15` and `@device:iphone_15` both find `iPhone 15`.
48
+ */
49
+ export declare function findDevice(value: string): string | undefined;
50
+ /** Why `@<key>:<value>` is unusable, or undefined when it is fine. Shared by lint and runtime. */
51
+ export declare function emulationTagProblem(key: EmulationTag, value: string): string | undefined;
52
+ /**
53
+ * The emulation a scenario's tags ask for. Keys the tags do not mention are absent, so the
54
+ * caller falls back to whatever the env or Playwright would have used.
55
+ *
56
+ * Throws on an unusable value or on two different values for the same key: lint reports both
57
+ * first, and a scenario that reaches the runner anyway must fail loudly rather than run under
58
+ * an emulation nobody asked for.
59
+ */
60
+ export declare function emulationFromTags(tags: readonly string[]): Emulation;
61
+ /** The browser-context option values a scenario gets: tags first, then what the project resolved. */
62
+ export interface ContextEmulationBase {
63
+ locale?: string;
64
+ timezoneId?: string;
65
+ colorScheme?: ColorScheme | null;
66
+ viewport?: ViewportSize | null;
67
+ userAgent?: string;
68
+ deviceScaleFactor?: number;
69
+ isMobile?: boolean;
70
+ hasTouch?: boolean;
71
+ browserName?: string;
72
+ }
73
+ export declare function applyEmulation<T extends ContextEmulationBase>(base: T, emulation: Emulation): Omit<T, 'browserName'>;
74
+ //# sourceMappingURL=emulation.d.ts.map
@@ -0,0 +1,154 @@
1
+ import { devices } from '@playwright/test';
2
+ import { SdodsError } from '../errors.js';
3
+ import { parseTagValues } from './tags.js';
4
+ /**
5
+ * Per-scenario browser emulation from tags: `@locale:<bcp47>`, `@timezone:<IANA>`,
6
+ * `@theme:<light|dark|no-preference>`, `@viewport:<W>x<H>` and `@device:<name>`.
7
+ *
8
+ * Six locales × dark/light used to mean twelve env yamls, because the only place a browser
9
+ * context's locale and colour scheme could be set was the env's `use:` block. The tags move that
10
+ * axis onto the scenario (or a Feature, or a tagged `Examples:` block), so one env file covers
11
+ * every combination.
12
+ *
13
+ * Locale and timezone are fixed when Playwright creates the browser context and cannot be
14
+ * changed afterwards, which is why these are tags resolved by fixtures before the context opens,
15
+ * rather than steps. Precedence, highest first: the tag, then the env's `use:`, then Playwright's
16
+ * default. `@viewport:` wins over the viewport of `@device:`.
17
+ *
18
+ * Kept pure (no fixtures, no browser) so the precedence and the validation that lint shares with
19
+ * the runtime are testable on their own — the `scenarioSkipReason` precedent.
20
+ */
21
+ export const EMULATION_TAGS = ['locale', 'timezone', 'theme', 'viewport', 'device'];
22
+ export const COLOR_SCHEMES = ['light', 'dark', 'no-preference'];
23
+ /** `320x640` → `{ width: 320, height: 640 }`, or undefined when it is not a usable size. */
24
+ export function parseViewport(value) {
25
+ const m = /^(\d{1,5})x(\d{1,5})$/.exec(value.trim());
26
+ if (!m)
27
+ return undefined;
28
+ const width = Number(m[1]);
29
+ const height = Number(m[2]);
30
+ return width > 0 && height > 0 ? { width, height } : undefined;
31
+ }
32
+ /**
33
+ * A tag value cannot contain a space, and every Playwright device name does (`iPhone 15`,
34
+ * `Galaxy S9+`). Names match case-insensitively with any run of spaces, hyphens or underscores
35
+ * treated as one separator, so `@device:iPhone-15` and `@device:iphone_15` both find `iPhone 15`.
36
+ */
37
+ export function findDevice(value) {
38
+ if (value in devices)
39
+ return value;
40
+ const norm = (s) => s.toLowerCase().replace(/[\s_-]+/g, '-');
41
+ const wanted = norm(value);
42
+ return Object.keys(devices).find((name) => norm(name) === wanted);
43
+ }
44
+ /** Why `@<key>:<value>` is unusable, or undefined when it is fine. Shared by lint and runtime. */
45
+ export function emulationTagProblem(key, value) {
46
+ switch (key) {
47
+ case 'locale':
48
+ try {
49
+ // Throws RangeError on anything that is not a structurally valid BCP 47 tag.
50
+ Intl.getCanonicalLocales(value);
51
+ return undefined;
52
+ }
53
+ catch {
54
+ return `@locale:${value} is not a BCP 47 locale (e.g. fr, fr-FR, pt-BR${value.includes('_') ? ' — use a hyphen, not an underscore' : ''}).`;
55
+ }
56
+ case 'timezone':
57
+ try {
58
+ // Accepts every IANA zone and its aliases (UTC, Etc/GMT+1) — `Intl.supportedValuesOf`
59
+ // lists canonical names only, so it would reject `UTC` on some runtimes.
60
+ new Intl.DateTimeFormat('en-US', { timeZone: value });
61
+ return undefined;
62
+ }
63
+ catch {
64
+ return `@timezone:${value} is not an IANA time zone (e.g. Europe/Paris, America/New_York, UTC).`;
65
+ }
66
+ case 'theme':
67
+ return COLOR_SCHEMES.includes(value)
68
+ ? undefined
69
+ : `@theme:${value} must be one of ${COLOR_SCHEMES.join(', ')}.`;
70
+ case 'viewport':
71
+ return parseViewport(value)
72
+ ? undefined
73
+ : `@viewport:${value} must be <width>x<height> in CSS pixels (e.g. @viewport:320x640).`;
74
+ case 'device':
75
+ return findDevice(value)
76
+ ? undefined
77
+ : `@device:${value} is not a Playwright device. Write the descriptor name with hyphens for spaces (e.g. @device:iPhone-15, @device:Pixel-7).`;
78
+ }
79
+ }
80
+ /**
81
+ * The emulation a scenario's tags ask for. Keys the tags do not mention are absent, so the
82
+ * caller falls back to whatever the env or Playwright would have used.
83
+ *
84
+ * Throws on an unusable value or on two different values for the same key: lint reports both
85
+ * first, and a scenario that reaches the runner anyway must fail loudly rather than run under
86
+ * an emulation nobody asked for.
87
+ */
88
+ export function emulationFromTags(tags) {
89
+ const one = (key) => {
90
+ const values = [...new Set(parseTagValues(tags, key))];
91
+ if (values.length > 1)
92
+ throw new SdodsError('CONFIG_INVALID', `Conflicting tags @${key}:${values.join(` and @${key}:`)} on one scenario.`, {
93
+ hint: `A scenario runs in one browser context, so it takes one @${key}: value. Tags on the Feature apply to every scenario in it — remove the duplicate from the scenario or the Feature.`,
94
+ });
95
+ const value = values[0];
96
+ if (value === undefined)
97
+ return undefined;
98
+ const problem = emulationTagProblem(key, value);
99
+ if (problem)
100
+ throw new SdodsError('CONFIG_INVALID', problem, { hint: 'Run `sdods lint`.' });
101
+ return value;
102
+ };
103
+ const out = {};
104
+ const locale = one('locale');
105
+ // Canonical form (`fr-fr` → `fr-FR`), so the context and `I use the locale` agree on spelling.
106
+ if (locale)
107
+ out.locale = Intl.getCanonicalLocales(locale)[0];
108
+ const timezone = one('timezone');
109
+ if (timezone)
110
+ out.timezoneId = timezone;
111
+ const theme = one('theme');
112
+ if (theme)
113
+ out.colorScheme = theme;
114
+ const viewport = one('viewport');
115
+ if (viewport)
116
+ out.viewport = parseViewport(viewport);
117
+ const deviceTag = one('device');
118
+ if (deviceTag) {
119
+ const name = findDevice(deviceTag);
120
+ const d = devices[name];
121
+ out.device = {
122
+ name,
123
+ userAgent: d.userAgent,
124
+ viewport: d.viewport,
125
+ deviceScaleFactor: d.deviceScaleFactor,
126
+ isMobile: d.isMobile,
127
+ hasTouch: d.hasTouch,
128
+ };
129
+ }
130
+ return out;
131
+ }
132
+ export function applyEmulation(base, emulation) {
133
+ const { browserName, ...rest } = base;
134
+ const d = emulation.device;
135
+ return {
136
+ ...rest,
137
+ ...(emulation.locale !== undefined ? { locale: emulation.locale } : {}),
138
+ ...(emulation.timezoneId !== undefined ? { timezoneId: emulation.timezoneId } : {}),
139
+ ...(emulation.colorScheme !== undefined ? { colorScheme: emulation.colorScheme } : {}),
140
+ ...(d
141
+ ? {
142
+ viewport: d.viewport,
143
+ userAgent: d.userAgent,
144
+ deviceScaleFactor: d.deviceScaleFactor,
145
+ hasTouch: d.hasTouch,
146
+ // Firefox rejects `isMobile` at context creation. The engine is the run target's, so a
147
+ // device tag on the firefox target emulates size, agent and touch only.
148
+ ...(browserName === 'firefox' ? {} : { isMobile: d.isMobile }),
149
+ }
150
+ : {}),
151
+ ...(emulation.viewport !== undefined ? { viewport: emulation.viewport } : {}),
152
+ };
153
+ }
154
+ //# sourceMappingURL=emulation.js.map
@@ -7,4 +7,5 @@ export * from './resolve.js';
7
7
  export * from './registry.js';
8
8
  export * from './workspace.js';
9
9
  export * from './runner.js';
10
+ export * from './emulation.js';
10
11
  //# sourceMappingURL=index.d.ts.map
@@ -7,4 +7,5 @@ export * from './resolve.js';
7
7
  export * from './registry.js';
8
8
  export * from './workspace.js';
9
9
  export * from './runner.js';
10
+ export * from './emulation.js';
10
11
  //# sourceMappingURL=index.js.map
@@ -228,7 +228,10 @@ export function buildRunnerConfig(registry, sel = {}) {
228
228
  ...browserUse(browser, p.channel),
229
229
  baseURL: cfg.env.ui.baseUrl,
230
230
  testIdAttribute: p.testIdAttribute,
231
- viewport: browser.startsWith('mobile') ? undefined : p.screenshots.viewport,
231
+ // Mobile targets keep their device descriptor's viewport. The key must be ABSENT, not
232
+ // `undefined`: Playwright treats an explicit undefined option as "use the default",
233
+ // so `viewport: undefined` reset Pixel 7 and iPhone 15 to a 1280x720 desktop window.
234
+ ...(browser.startsWith('mobile') ? {} : { viewport: p.screenshots.viewport }),
232
235
  ...envUse,
233
236
  ...evidenceUse,
234
237
  sdods: { project: p.slug, layer, browser },
@@ -5,7 +5,7 @@ export declare const RUNNER_SPECIAL_TAGS: RegExp;
5
5
  /** @deprecated Use {@link RUNNER_SPECIAL_TAGS}. Removed in the next minor. */
6
6
  export declare const PLAYWRIGHT_BDD_SPECIAL: RegExp;
7
7
  export declare const VALUE_TAG: RegExp;
8
- export declare const KNOWN_VALUE_TAGS: readonly ["env", "user", "data", "har", "jira", "github", "skip", "title", "flag"];
8
+ export declare const KNOWN_VALUE_TAGS: readonly ["env", "user", "data", "har", "jira", "github", "skip", "title", "flag", "matrix", "req", "locale", "timezone", "theme", "viewport", "device"];
9
9
  /**
10
10
  * Values `@skip:<browser>` accepts. Derived from the schema rather than copied, so adding a browser
11
11
  * to `BrowserSchema` is the only edit needed — and the lint message lists the new name for free.
@@ -17,6 +17,13 @@ export const KNOWN_VALUE_TAGS = [
17
17
  'skip',
18
18
  'title',
19
19
  'flag',
20
+ 'matrix',
21
+ 'req',
22
+ 'locale',
23
+ 'timezone',
24
+ 'theme',
25
+ 'viewport',
26
+ 'device',
20
27
  ];
21
28
  /**
22
29
  * Values `@skip:<browser>` accepts. Derived from the schema rather than copied, so adding a browser
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' | 'HEAL_FAILED' | 'LINT_FAILED' | 'RUN_FAILED' | 'HAR_MISS' | 'DB_REQUIRED' | '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' | 'HEAL_FAILED' | 'LINT_FAILED' | 'RUN_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;
@@ -1,8 +1,26 @@
1
1
  import type { ApiSnapshot } from '@sdods/contracts';
2
+ /**
3
+ * A `Map` of HTTP header names to values whose names are case-insensitive, as HTTP's are.
4
+ *
5
+ * Names are stored lower-cased, so `set('X-API-Key')` followed by `delete('x-api-key')` removes
6
+ * the header, `get('cookie')` finds a header set as `Cookie`, and when a scenario sets the same
7
+ * header under two casings the last `set` wins. The client lower-cases names when it builds a
8
+ * request anyway, so what goes on the wire and into the evidence is unchanged.
9
+ *
10
+ * A plain `new Map(headers)` clone keeps the lower-cased names; restore it by `set`-ting each
11
+ * entry back into this map rather than replacing it.
12
+ */
13
+ export declare class HeaderMap extends Map<string, string> {
14
+ set(name: string, value: string): this;
15
+ get(name: string): string | undefined;
16
+ has(name: string): boolean;
17
+ delete(name: string): boolean;
18
+ }
2
19
  /** Per-scenario API state: last response, chained variables, pending headers/query. No module globals. */
3
20
  export declare class ApiContext {
4
21
  readonly vars: VarStore;
5
- readonly headers: Map<string, string>;
22
+ /** Pending request headers. Names are case-insensitive (stored lower-cased). */
23
+ readonly headers: HeaderMap;
6
24
  readonly query: Map<string, string>;
7
25
  readonly history: ApiSnapshot[];
8
26
  /**
@@ -1,7 +1,34 @@
1
+ /**
2
+ * A `Map` of HTTP header names to values whose names are case-insensitive, as HTTP's are.
3
+ *
4
+ * Names are stored lower-cased, so `set('X-API-Key')` followed by `delete('x-api-key')` removes
5
+ * the header, `get('cookie')` finds a header set as `Cookie`, and when a scenario sets the same
6
+ * header under two casings the last `set` wins. The client lower-cases names when it builds a
7
+ * request anyway, so what goes on the wire and into the evidence is unchanged.
8
+ *
9
+ * A plain `new Map(headers)` clone keeps the lower-cased names; restore it by `set`-ting each
10
+ * entry back into this map rather than replacing it.
11
+ */
12
+ export class HeaderMap extends Map {
13
+ // No constructor: Map's constructor adds iterable entries through `this.set`, so they normalise too.
14
+ set(name, value) {
15
+ return super.set(name.toLowerCase(), value);
16
+ }
17
+ get(name) {
18
+ return super.get(name.toLowerCase());
19
+ }
20
+ has(name) {
21
+ return super.has(name.toLowerCase());
22
+ }
23
+ delete(name) {
24
+ return super.delete(name.toLowerCase());
25
+ }
26
+ }
1
27
  /** Per-scenario API state: last response, chained variables, pending headers/query. No module globals. */
2
28
  export class ApiContext {
3
29
  vars = new VarStore();
4
- headers = new Map();
30
+ /** Pending request headers. Names are case-insensitive (stored lower-cased). */
31
+ headers = new HeaderMap();
5
32
  query = new Map();
6
33
  history = [];
7
34
  /**
@@ -2,6 +2,7 @@ import { join } from 'node:path';
2
2
  import { test as base, createBdd } from 'playwright-bdd';
3
3
  import { ProjectRegistry } from '../config/registry.js';
4
4
  import { parseTagValue, scenarioSkipReason } from '../config/tags.js';
5
+ import { applyEmulation, emulationFromTags } from '../config/emulation.js';
5
6
  import { noopAuth } from '../auth/index.js';
6
7
  import { ApiClient, isolatedRequestFactory } from '../api/client.js';
7
8
  import { CompositeDataProvider } from '../data/provider.js';
@@ -197,6 +198,41 @@ export const test = base.extend({
197
198
  return use(undefined);
198
199
  await use(await authCache.ensure(user, auth, browser));
199
200
  },
201
+ // ── per-scenario emulation: @locale: @timezone: @theme: @viewport: @device: ────
202
+ //
203
+ // Mirrors `storageState` above: Playwright builds the browser context from these option
204
+ // fixtures, so overriding them is the one place a tag can reach the context BEFORE it opens —
205
+ // which locale and timezone require, because neither can change on a live context. Each
206
+ // override takes its own previous value (the env's `use:` via the runner project, or
207
+ // Playwright's default) and replaces it only when the scenario is tagged, so tags win over the
208
+ // env and an untagged scenario is untouched.
209
+ $sdodsEmulation: async ({ $tags }, use) => {
210
+ await use(emulationFromTags($tags));
211
+ },
212
+ locale: async ({ locale, $sdodsEmulation }, use) => {
213
+ await use(applyEmulation({ locale }, $sdodsEmulation).locale);
214
+ },
215
+ timezoneId: async ({ timezoneId, $sdodsEmulation }, use) => {
216
+ await use(applyEmulation({ timezoneId }, $sdodsEmulation).timezoneId);
217
+ },
218
+ colorScheme: async ({ colorScheme, $sdodsEmulation }, use) => {
219
+ await use(applyEmulation({ colorScheme }, $sdodsEmulation).colorScheme);
220
+ },
221
+ viewport: async ({ viewport, $sdodsEmulation }, use) => {
222
+ await use(applyEmulation({ viewport }, $sdodsEmulation).viewport);
223
+ },
224
+ userAgent: async ({ userAgent, $sdodsEmulation }, use) => {
225
+ await use(applyEmulation({ userAgent }, $sdodsEmulation).userAgent);
226
+ },
227
+ deviceScaleFactor: async ({ deviceScaleFactor, $sdodsEmulation }, use) => {
228
+ await use(applyEmulation({ deviceScaleFactor }, $sdodsEmulation).deviceScaleFactor);
229
+ },
230
+ hasTouch: async ({ hasTouch, $sdodsEmulation }, use) => {
231
+ await use(applyEmulation({ hasTouch }, $sdodsEmulation).hasTouch);
232
+ },
233
+ isMobile: async ({ isMobile, browserName, $sdodsEmulation }, use) => {
234
+ await use(applyEmulation({ isMobile, browserName }, $sdodsEmulation).isMobile);
235
+ },
200
236
  heal: async ({ config, healHistory, scenario, $bddContext }, use, testInfo) => {
201
237
  await use(new Healer({
202
238
  config: config.project.heal,
@@ -1,4 +1,5 @@
1
1
  import type { BrowserName, Layer } from '@sdods/contracts';
2
+ import type { Emulation } from '../config/emulation.js';
2
3
  import type { ProjectRegistry } from '../config/registry.js';
3
4
  import type { HarMode, ResolvedConfig } from '../config/resolve.js';
4
5
  import type { EnvConfig } from '@sdods/contracts';
@@ -48,5 +49,7 @@ export interface TestFixtures {
48
49
  $sdodsAnnotations: void;
49
50
  /** auto fixture: applies @env:, @skip:<browser>, @quarantine and @flag: */
50
51
  $sdodsTagGate: void;
52
+ /** What the scenario's @locale: @timezone: @theme: @viewport: @device: tags ask for. */
53
+ $sdodsEmulation: Emulation;
51
54
  }
52
55
  //# sourceMappingURL=types.d.ts.map
package/dist/index.d.ts CHANGED
@@ -8,11 +8,14 @@ export * from './heal/index.js';
8
8
  export * from './shots/index.js';
9
9
  export * from './lint/index.js';
10
10
  export * from './evidence/index.js';
11
+ export * from './mail/index.js';
11
12
  export { BasePage } from './pages/base-page.js';
12
13
  export { PageRegistry } from './fixtures/pages.js';
13
14
  export { AuthStateCache } from './fixtures/auth.js';
14
15
  export { ScenarioMeta } from './fixtures/scenario.js';
16
+ export { HeaderMap } from './fixtures/api-context.js';
15
17
  export type { TestFixtures, WorkerFixtures, SdodsOption } from './fixtures/types.js';
16
18
  export { coreStepsGlob, coreStepsPatterns, coreStepNames, coreStepsDir } from './steps/glob.js';
17
19
  export { VERSION } from './version.js';
20
+ export * from './matrix/index.js';
18
21
  //# sourceMappingURL=index.d.ts.map
package/dist/index.js CHANGED
@@ -8,10 +8,13 @@ export * from './heal/index.js';
8
8
  export * from './shots/index.js';
9
9
  export * from './lint/index.js';
10
10
  export * from './evidence/index.js';
11
+ export * from './mail/index.js';
11
12
  export { BasePage } from './pages/base-page.js';
12
13
  export { PageRegistry } from './fixtures/pages.js';
13
14
  export { AuthStateCache } from './fixtures/auth.js';
14
15
  export { ScenarioMeta } from './fixtures/scenario.js';
16
+ export { HeaderMap } from './fixtures/api-context.js';
15
17
  export { coreStepsGlob, coreStepsPatterns, coreStepNames, coreStepsDir } from './steps/glob.js';
16
18
  export { VERSION } from './version.js';
19
+ export * from './matrix/index.js';
17
20
  //# sourceMappingURL=index.js.map
@@ -3,8 +3,11 @@ import { join, relative, resolve } from 'node:path';
3
3
  import { execa } from 'execa';
4
4
  import { BROWSERS_FOR_SKIP, KNOWN_VALUE_TAGS, LAYER_TAGS, RUNNER_SPECIAL_TAGS, VALUE_TAG, taxonomyFromProject, } from '../config/tags.js';
5
5
  import { moduleForFeature } from '../config/workspace.js';
6
+ import { emulationTagProblem } from '../config/emulation.js';
6
7
  import { parseFeatureFile, scenariosOf } from './gherkin.js';
8
+ import { projectRequirements } from '../analyze/traceability.js';
7
9
  import { checkStepAmbiguity } from './steps.js';
10
+ import { ROLES_MATRIX_FILE, planMatrixExpansion } from '../matrix/index.js';
8
11
  export * from './gherkin.js';
9
12
  export function listFeatureFiles(root) {
10
13
  const out = [];
@@ -32,6 +35,21 @@ export async function lintProject(opts) {
32
35
  const warnings = [];
33
36
  const rel = (f) => relative(project.root, f).replace(/\\/g, '/');
34
37
  const parsedFeatures = [];
38
+ const matrixPlan = planMatrixExpansion(project, files);
39
+ // `@req:<id>` is opaque unless the project lists its requirements; then an id must be in the list.
40
+ let declaredReqs = null;
41
+ try {
42
+ const defs = projectRequirements(project);
43
+ declaredReqs = defs ? new Set(defs.map((d) => d.id)) : null;
44
+ }
45
+ catch (e) {
46
+ errors.push({
47
+ severity: 'error',
48
+ rule: 'traceability/requirements',
49
+ message: e.message,
50
+ file: 'sdods.project.yaml',
51
+ });
52
+ }
35
53
  for (const file of files) {
36
54
  const parsed = parseFeatureFile(file);
37
55
  parsedFeatures.push(parsed);
@@ -158,6 +176,31 @@ export async function lintProject(opts) {
158
176
  ...loc,
159
177
  });
160
178
  break;
179
+ case 'matrix':
180
+ if (!matrixPlan.loaded)
181
+ errors.push({
182
+ severity: 'error',
183
+ rule: 'tags/matrix',
184
+ message: `@matrix:${value} needs a ${ROLES_MATRIX_FILE} in the project.`,
185
+ ...loc,
186
+ });
187
+ else if (!matrixPlan.loaded.declared.includes(value))
188
+ errors.push({
189
+ severity: 'error',
190
+ rule: 'tags/matrix',
191
+ message: `@matrix:${value} is not a matrix in ${ROLES_MATRIX_FILE} (${matrixPlan.loaded.declared.join(', ') || 'none'}).`,
192
+ ...loc,
193
+ });
194
+ break;
195
+ case 'req':
196
+ if (declaredReqs && !declaredReqs.has(value))
197
+ errors.push({
198
+ severity: 'error',
199
+ rule: 'tags/req',
200
+ message: `@req:${value} is not listed in ${project.traceability?.requirements}.`,
201
+ ...loc,
202
+ });
203
+ break;
161
204
  case 'skip':
162
205
  if (!BROWSERS_FOR_SKIP.includes(value))
163
206
  errors.push({
@@ -167,6 +210,28 @@ export async function lintProject(opts) {
167
210
  ...loc,
168
211
  });
169
212
  break;
213
+ case 'locale':
214
+ case 'timezone':
215
+ case 'theme':
216
+ case 'viewport':
217
+ case 'device': {
218
+ const problem = emulationTagProblem(key, value);
219
+ if (problem)
220
+ errors.push({ severity: 'error', rule: `tags/${key}`, message: problem, ...loc });
221
+ // One browser context per scenario, so one value per key (Feature tags included).
222
+ // Outlines are exempt: their tag list folds in every Examples block, and one block
223
+ // per locale (`@locale:fr` / `@locale:de`) is exactly the pattern these tags enable.
224
+ // The runner still refuses a real conflict on a single example.
225
+ const distinct = [...new Set(sc.tags.filter((t) => t.startsWith(`@${key}:`)))];
226
+ if (!sc.isOutline && distinct.length > 1 && distinct[0] === tag)
227
+ errors.push({
228
+ severity: 'error',
229
+ rule: `tags/${key}`,
230
+ message: `Conflicting ${distinct.join(' and ')}: a scenario takes one @${key}: value.`,
231
+ ...loc,
232
+ });
233
+ break;
234
+ }
170
235
  default:
171
236
  if (!KNOWN_VALUE_TAGS.includes(key))
172
237
  warnings.push({
@@ -185,6 +250,14 @@ export async function lintProject(opts) {
185
250
  ...loc,
186
251
  });
187
252
  }
253
+ if (project.traceability?.require && !sc.tags.some((t) => t.startsWith('@req:'))) {
254
+ errors.push({
255
+ severity: 'error',
256
+ rule: 'tags/req-missing',
257
+ message: 'Scenario carries no @req:<id> tag (traceability.require is on). Tag the scenario, or its Feature to cover every scenario in the file.',
258
+ ...loc,
259
+ });
260
+ }
188
261
  if (mod) {
189
262
  const missing = mod.tags.filter((t) => !sc.tags.includes(t));
190
263
  if (missing.length)
@@ -216,6 +289,19 @@ export async function lintProject(opts) {
216
289
  seen.set(key, sc.line);
217
290
  }
218
291
  }
292
+ // Role matrix (#118): the yaml, its roles and pool accounts, template placement, and staleness.
293
+ for (const p of [...matrixPlan.problems, ...matrixPlan.files.flatMap((f) => f.problems)]) {
294
+ const { severity, rule, message, file, line } = p;
295
+ (severity === 'error' ? errors : warnings).push({ severity, rule, message, file, line });
296
+ }
297
+ for (const f of matrixPlan.files.filter((x) => x.changed))
298
+ warnings.push({
299
+ severity: 'warning',
300
+ rule: 'matrix/stale',
301
+ message: `Generated Examples are out of date with ${ROLES_MATRIX_FILE}; run \`sdods matrix expand -p ${project.slug}\`.`,
302
+ file: f.path,
303
+ line: 1,
304
+ });
219
305
  // A phrasing matched by two definitions stops bddgen generating anything (#60).
220
306
  const ambiguity = await checkStepAmbiguity({ project, features: parsedFeatures });
221
307
  errors.push(...ambiguity.errors);
@@ -0,0 +1,4 @@
1
+ export * from './profile.js';
2
+ export * from './script.js';
3
+ export * from './run.js';
4
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1,4 @@
1
+ export * from './profile.js';
2
+ export * from './script.js';
3
+ export * from './run.js';
4
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1,22 @@
1
+ import { type EnvConfig, type LoadProfile } from '@sdods/contracts';
2
+ export declare const LOAD_DIR = "load";
3
+ /** Profile names under `projects/<slug>/load/`, without the extension. */
4
+ export declare function listLoadProfiles(projectRoot: string): string[];
5
+ export declare function loadProfileFile(projectRoot: string, name: string): string | undefined;
6
+ export declare function parseLoadProfile(raw: unknown, file?: string): LoadProfile;
7
+ export declare function readLoadProfile(projectRoot: string, name: string): LoadProfile;
8
+ /** The most virtual users the profile reaches: `vus`, or the highest stage target. */
9
+ export declare function peakVus(profile: LoadProfile): number;
10
+ /**
11
+ * Why this environment may not take this profile. Empty means it may.
12
+ * Checked before every real run; `--dry-run` only reports it.
13
+ */
14
+ export declare function loadGuardProblems(env: EnvConfig, profile: LoadProfile): string[];
15
+ /**
16
+ * The load goes to `SDODS_API_BASE_URL` when it is set. The opt-in is for the environment file, so
17
+ * an override that points at another host is refused unless that file sets
18
+ * `load.allowBaseUrlOverride: true`. Both URLs are compared without trailing slashes.
19
+ */
20
+ export declare function baseUrlOverrideProblems(env: EnvConfig, envBaseUrl: string, override: string | undefined): string[];
21
+ export declare function assertLoadAllowed(env: EnvConfig, profile: LoadProfile, extraProblems?: string[]): void;
22
+ //# sourceMappingURL=profile.d.ts.map