@bloom-perf/yaml-pptr 2.0.2 → 3.1.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.
package/README.md CHANGED
@@ -11,9 +11,11 @@
11
11
  ## Installation
12
12
 
13
13
  ```bash
14
- npm install @bloom-perf/yaml-pptr
14
+ npm install @bloom-perf/yaml-pptr puppeteer
15
15
  ```
16
16
 
17
+ `puppeteer` is a peer dependency (`^25.0.0`): your project controls its version.
18
+
17
19
  ## Quick Start
18
20
 
19
21
  ```typescript
@@ -108,7 +110,93 @@ readYamlAndInterpret(yaml)
108
110
  ### 3. Run it
109
111
 
110
112
  ```bash
111
- npx ts-node src/runner.ts
113
+ npx tsx src/runner.ts
114
+ ```
115
+
116
+ ## API Options
117
+
118
+ ```typescript
119
+ await readYamlAndInterpret(yaml, {
120
+ headless: true, // default: true
121
+ launchOptions: { // extra Puppeteer launch options, per browser
122
+ firefox: { executablePath: '/path/to/firefox' },
123
+ },
124
+ browsers: { chrome: myBrowser }, // reuse already launched browsers (not closed)
125
+ logger: myLogger,
126
+ onPage: async (page, ctx) => { /* page hook */ },
127
+ });
128
+ ```
129
+
130
+ | Option | Default | Description |
131
+ | ------ | ------- | ----------- |
132
+ | `headless` | `true` | Run launched browsers headless |
133
+ | `launchOptions` | `{}` | Puppeteer `LaunchOptions` per browser; `args` are appended to the defaults |
134
+ | `browsers` | `{}` | Pre-launched browsers to use instead of launching new ones |
135
+ | `logger` | console | Custom logger (`debug`, `info`, `warn`, `error`) |
136
+ | `onPage` | - | Callback invoked on every new page with `{ scenarioName, workerIndex, iteration }` |
137
+
138
+ ### Firefox
139
+
140
+ Puppeteer drives Firefox through WebDriver BiDi, which requires a recent Firefox. Install the version matching your Puppeteer release with:
141
+
142
+ ```bash
143
+ npx puppeteer browsers install firefox
144
+ ```
145
+
146
+ To use another Firefox installed on the machine, pass its path with `launchOptions.firefox.executablePath`.
147
+
148
+ ### Error handling
149
+
150
+ The configuration is fully checked before anything runs: YAML syntax, schema (an unknown key such as a typo is an error), and environment variables. If it is invalid, `readYamlAndInterpret` rejects with a `ConfigurationError` listing every problem with its location, and no browser page is opened:
151
+
152
+ ```text
153
+ ConfigurationError: Invalid scenario configuration:
154
+ ✖ Unrecognized key: "iteration"
155
+ → at scenarios[0]
156
+ ✖ Invalid input: expected int, received number
157
+ → at scenarios[0].workers
158
+ ```
159
+
160
+ Then every scenario, worker and iteration always runs to completion: a failed iteration does not stop the next iterations nor the other workers, and its page is always closed. If anything failed, `readYamlAndInterpret` then rejects with an `AggregateError` whose `errors` contain:
161
+
162
+ - `WorkerError` — an iteration failed (`scenarioName`, `workerIndex`, `iteration`, original error in `cause`)
163
+ - `BrowserLaunchError` — a browser could not be launched (`browser`, original error in `cause`)
164
+
165
+ ```typescript
166
+ import { readYamlAndInterpret, ConfigurationError } from '@bloom-perf/yaml-pptr';
167
+
168
+ try {
169
+ await readYamlAndInterpret(yaml);
170
+ } catch (e) {
171
+ if (e instanceof ConfigurationError) {
172
+ console.error(e.message); // invalid YAML, unknown key, missing variable...
173
+ } else if (e instanceof AggregateError) {
174
+ for (const err of e.errors) console.error(err.message); // WorkerError / BrowserLaunchError
175
+ }
176
+ process.exit(1);
177
+ }
178
+ ```
179
+
180
+ ## Editor Support
181
+
182
+ The package ships a JSON Schema of the YAML configuration (`dist/schema.json`), generated from the very schema used to validate it at runtime. With the [YAML extension for VS Code](https://marketplace.visualstudio.com/items?itemName=redhat.vscode-yaml) (or any editor using `yaml-language-server`), you get completion, documentation on hover, and errors (unknown key, wrong type...) while typing.
183
+
184
+ Add this first line to a scenario file:
185
+
186
+ ```yaml
187
+ # yaml-language-server: $schema=https://unpkg.com/@bloom-perf/yaml-pptr@3/dist/schema.json
188
+ scenarios:
189
+ - name: Login
190
+ ```
191
+
192
+ With the package installed, a local path works too (relative to the YAML file), e.g. `$schema=../node_modules/@bloom-perf/yaml-pptr/dist/schema.json`. To apply it to several files, map them in `.vscode/settings.json`:
193
+
194
+ ```json
195
+ {
196
+ "yaml.schemas": {
197
+ "https://unpkg.com/@bloom-perf/yaml-pptr@3/dist/schema.json": ["scenarios/*.yaml"]
198
+ }
199
+ }
112
200
  ```
113
201
 
114
202
  ## YAML Reference
@@ -119,13 +207,37 @@ npx ts-node src/runner.ts
119
207
  | -------- | ---- | ------- | ----------- |
120
208
  | `name` | string | `Scenario N` | Scenario name |
121
209
  | `browser` | `chrome` \| `firefox` | `chrome` | Browser to use |
122
- | `run` | `PARALLEL` \| `SEQUENTIAL` | `PARALLEL` | Worker start strategy |
123
- | `initialDelaySeconds` | number | `0` | Delay before starting |
124
- | `workers` | number | `1` | Number of parallel workers |
125
- | `iterations` | number | `1` | Repetitions per worker |
210
+ | `run` | `PARALLEL` \| `SEQUENTIAL` | `PARALLEL` | Worker start strategy: `PARALLEL` starts all workers after `initialDelaySeconds`; `SEQUENTIAL` starts worker *n* after *n* × `initialDelaySeconds` |
211
+ | `initialDelaySeconds` | number | `0` | Start delay, applied once per worker before its first iteration (see `run`) |
212
+ | `workers` | integer ≥ 1 | `1` | Number of parallel workers |
213
+ | `iterations` | integer ≥ 1 | `1` | Repetitions per worker |
126
214
  | `location` | string | - | Starting URL |
127
- | `steps` | array | - | Simplified syntax |
128
- | `actions` | array | - | Full syntax with options |
215
+ | `steps` | array | - | Simplified syntax (full actions are accepted too) |
216
+ | `actions` | array | - | Full syntax with options, run after `steps` |
217
+
218
+ All the scenarios of a file start at the same time, whatever their browser: each one is a population of users with its own `workers`, `iterations` and start strategy. Unknown keys are rejected (see [Error handling](#error-handling)).
219
+
220
+ ### Selectors
221
+
222
+ Every selector (`click`, `input`, `CLICK`, `TYPE`, `WAIT_FOR_SELECTOR`) accepts a CSS selector or any [Puppeteer selector](https://pptr.dev/guides/page-interactions), so XPath, text and ARIA queries need no special syntax:
223
+
224
+ | Kind | Example |
225
+ | ---- | ------- |
226
+ | CSS | `#login-button`, `form .submit` |
227
+ | XPath | `::-p-xpath(//button[@type='submit'])` or `xpath///button[@type='submit']` |
228
+ | Text | `::-p-text(Sign in)` |
229
+ | ARIA | `::-p-aria(Sign in)`, `::-p-aria([name="Sign in"][role="button"])` |
230
+ | Shadow DOM | `my-app >>> button` (any depth), `my-app >>>> button` (direct shadow root) |
231
+
232
+ ```yaml
233
+ steps:
234
+ - input:
235
+ selector: "::-p-xpath(//input[@name='email'])"
236
+ text: "${APP_USER}"
237
+ - click: "::-p-text(Sign in)"
238
+ ```
239
+
240
+ `${VAR}` placeholders are resolved in selectors too.
129
241
 
130
242
  ### Steps (Simplified Syntax)
131
243
 
@@ -164,7 +276,17 @@ npx ts-node src/runner.ts
164
276
 
165
277
  ### Actions (Full Syntax)
166
278
 
167
- Use `actions` instead of `steps` when you need advanced options.
279
+ Use the full syntax when you need advanced options. Full actions can be listed in `actions` (run after `steps`), or written directly among `steps` to mix both syntaxes in the order you need:
280
+
281
+ ```yaml
282
+ steps:
283
+ - click: "#open-menu"
284
+ - type: WAIT_FOR_SELECTOR
285
+ selector: ".menu"
286
+ options:
287
+ visible: true
288
+ - click: ".menu .logout"
289
+ ```
168
290
 
169
291
  #### GOTO
170
292
 
@@ -248,6 +370,8 @@ Use `actions` instead of `steps` when you need advanced options.
248
370
  script: "document.title"
249
371
  ```
250
372
 
373
+ The script is never interpolated with environment variables (injecting values into code would be unsafe).
374
+
251
375
  #### SET_VIEWPORT
252
376
 
253
377
  ```yaml
@@ -262,6 +386,8 @@ Use `actions` instead of `steps` when you need advanced options.
262
386
  - type: PAUSE # Press Enter to continue
263
387
  ```
264
388
 
389
+ Meant for debugging in an interactive terminal. When there is none (CI, container, piped input), the PAUSE is ignored with a warning instead of blocking forever.
390
+
265
391
  #### CLOSE
266
392
 
267
393
  ```yaml
@@ -270,19 +396,37 @@ Use `actions` instead of `steps` when you need advanced options.
270
396
 
271
397
  ## Environment Variables
272
398
 
273
- Use `$VAR_NAME` syntax in `location` or `navigate`:
399
+ Keep URLs and credentials out of the YAML with `${VAR_NAME}` placeholders. They are resolved before anything runs: a missing variable is a `ConfigurationError`.
274
400
 
275
401
  ```yaml
276
- location: "$BASE_URL"
402
+ scenarios:
403
+ - name: Login
404
+ location: "${BASE_URL}/login"
405
+ steps:
406
+ - input:
407
+ selector: "#username"
408
+ text: "${APP_USER}"
409
+ - input:
410
+ selector: "#password"
411
+ text: "${APP_PASSWORD}"
412
+ - click: "#login-button"
277
413
  ```
278
414
 
279
415
  ```bash
280
- BASE_URL=https://example.com npx ts-node runner.ts
416
+ BASE_URL=https://example.com APP_USER=alice APP_PASSWORD=... npx tsx runner.ts
281
417
  ```
282
418
 
419
+ | Syntax | Where | Notes |
420
+ | ------ | ----- | ----- |
421
+ | `${VAR}` | Anywhere in `location`, `navigate`, GOTO `url`/`referer`, selectors, typed `text`, SCREENSHOT `path` | `$${` produces a literal `${` |
422
+ | `$VAR` | As the whole value of `location`, `navigate`, GOTO `url`, SCREENSHOT `path` | Elsewhere a `$` is kept as is (e.g. a password like `$ecret`) |
423
+ | `$VAR[workerIndex]` | As the whole value of `location`, `navigate` | One value per worker, see below |
424
+
425
+ `EVALUATE` scripts are never interpolated. Typed texts and resolved values are never logged.
426
+
283
427
  ### Indexed Variables
284
428
 
285
- Assign different values to each worker using `$VAR[workerIndex]`:
429
+ Assign different values to each worker using `$VAR[workerIndex]` (comma-separated values). There must be at least one non-empty value per worker, otherwise it is a `ConfigurationError`:
286
430
 
287
431
  ```yaml
288
432
  scenarios:
@@ -294,7 +438,7 @@ scenarios:
294
438
  ```
295
439
 
296
440
  ```bash
297
- URLS="https://site1.com,https://site2.com,https://site3.com" npx ts-node runner.ts
441
+ URLS="https://site1.com,https://site2.com,https://site3.com" npx tsx runner.ts
298
442
  ```
299
443
 
300
444
  Worker 0 gets `site1.com`, worker 1 gets `site2.com`, etc.
@@ -324,6 +468,9 @@ npm test
324
468
  # Run tests with coverage
325
469
  npm run test:coverage
326
470
 
471
+ # Type check
472
+ npm run typecheck
473
+
327
474
  # Build
328
475
  npm run build
329
476
  ```
@@ -0,0 +1,26 @@
1
+ /**
2
+ * Raised before anything runs when the configuration is invalid: YAML syntax,
3
+ * schema validation (unknown key, wrong type...) or environment variable
4
+ * resolution (missing variable, not enough per-worker values...).
5
+ */
6
+ export declare class ConfigurationError extends Error {
7
+ constructor(message: string, options?: ErrorOptions);
8
+ }
9
+ /**
10
+ * Raised when an iteration of a scenario worker fails. Carries the scenario
11
+ * name, the worker index and the iteration so the caller knows exactly where
12
+ * the failure happened.
13
+ */
14
+ export declare class WorkerError extends Error {
15
+ readonly scenarioName: string;
16
+ readonly workerIndex: number;
17
+ readonly iteration: number;
18
+ constructor(scenarioName: string, workerIndex: number, iteration: number, cause: unknown);
19
+ }
20
+ /**
21
+ * Raised when a browser could not be launched (missing binary, bad options...).
22
+ */
23
+ export declare class BrowserLaunchError extends Error {
24
+ readonly browser: string;
25
+ constructor(browser: string, cause: unknown);
26
+ }
@@ -103,7 +103,7 @@ export interface CloseAction extends BaseAction {
103
103
  }
104
104
  export interface EvaluateAction extends BaseAction {
105
105
  actionType: ActionType.Evaluate;
106
- script: string | ((...args: any[]) => any);
106
+ script: string;
107
107
  }
108
108
  export interface SetViewportAction extends BaseAction {
109
109
  actionType: ActionType.SetViewport;
package/dist/index.d.ts CHANGED
@@ -1,12 +1,32 @@
1
1
  import * as p from 'puppeteer';
2
- import { SupportedBrowser, OnPageCallback } from './core/model';
3
- import { YpLogger } from './core/logger';
4
- export type { ScenarioContext, OnPageCallback } from './core/model';
2
+ import { SupportedBrowser, OnPageCallback } from './core/model.js';
3
+ import { YpLogger } from './core/logger.js';
4
+ export type { ScenarioContext, OnPageCallback } from './core/model.js';
5
+ export { WorkerError, BrowserLaunchError, ConfigurationError } from './core/errors.js';
5
6
  export type ReadYamlOptions = {
7
+ /** Already launched browsers to use instead of launching new ones. They are not closed. */
6
8
  browsers?: {
7
9
  [key in SupportedBrowser]?: p.Browser;
8
10
  };
9
11
  logger?: YpLogger;
10
12
  onPage?: OnPageCallback;
13
+ /** Run launched browsers in headless mode. Defaults to `true`. */
14
+ headless?: boolean;
15
+ /** Extra Puppeteer launch options per browser (e.g. `executablePath`). `args` are appended to the defaults. */
16
+ launchOptions?: {
17
+ [key in SupportedBrowser]?: p.LaunchOptions;
18
+ };
11
19
  };
20
+ /**
21
+ * Parses and validates the YAML, resolves environment variables, then runs
22
+ * every scenario. All scenarios start at the same time (each one with its own
23
+ * workers and start strategy), whatever the browser they target.
24
+ *
25
+ * - An invalid configuration (YAML syntax, unknown key, missing environment
26
+ * variable...) rejects with a `ConfigurationError` before anything runs.
27
+ * - Otherwise all scenarios are always run to completion. If anything failed
28
+ * (browser launch or iteration), the promise rejects with an `AggregateError`
29
+ * whose `errors` are `BrowserLaunchError` / `WorkerError` instances, in
30
+ * scenario order.
31
+ */
12
32
  export declare const readYamlAndInterpret: (yamlContent: string, options?: ReadYamlOptions) => Promise<void>;
package/dist/index.js CHANGED
@@ -1 +1,2 @@
1
- import*as x from"puppeteer";import*as k from"js-yaml";import*as t from"zod";var T=t.enum(["load","domcontentloaded","networkidle0","networkidle2"]),R=t.enum(["left","right","middle"]),P=t.enum(["chrome","firefox"]),F=t.object({timeout:t.number().optional(),waitUntil:t.union([T,T.array()]).optional(),referer:t.string().optional()}).optional(),L=t.object({type:t.literal("WAIT"),milliseconds:t.number()}),B=t.object({button:R.optional(),clickCount:t.number().optional(),delay:t.number().optional()}).optional(),j=t.object({delay:t.number().optional()}).optional(),U=t.object({visible:t.boolean().optional(),hidden:t.boolean().optional(),timeout:t.number().optional()}).optional(),V=t.object({type:t.enum(["png","jpeg"]).optional(),quality:t.number().optional(),fullPage:t.boolean().optional(),clip:t.object({x:t.number(),y:t.number(),width:t.number(),height:t.number()}).optional(),omitBackground:t.boolean().optional(),encoding:t.enum(["base64","binary"]).optional()}).optional(),_=t.object({type:t.literal("GOTO"),url:t.string(),options:F}),G=t.object({type:t.literal("CLICK"),selector:t.string(),options:B}),I=t.object({type:t.literal("TYPE"),selector:t.string(),text:t.string(),options:j}),N=t.object({type:t.literal("WAIT_FOR_SELECTOR"),selector:t.string(),options:U}),M=t.object({type:t.literal("WAIT_FOR_TIMEOUT"),timeout:t.number()}),D=t.object({type:t.literal("PAUSE")}),q=t.object({type:t.literal("SCREENSHOT"),path:t.string(),options:V}),H=t.object({type:t.literal("EVALUATE"),script:t.union([t.string(),t.function()])}),K=t.object({type:t.literal("SET_VIEWPORT"),width:t.number(),height:t.number()}),J=t.object({type:t.literal("CLOSE")}),z=[_,G,I,N,M,D,q,H,K,J,L];if(z.length===0)throw new Error("Le tableau des sch\xE9mas d'actions est vide.");var Q=z,Z=t.discriminatedUnion("type",Q),X=t.object({navigate:t.string()}),Y=t.literal("waitForever"),tt=t.object({wait:t.number()}),et=t.object({click:t.string()}),ot=t.object({input:t.object({selector:t.string(),text:t.string()})}),it=t.union([X,Y,tt,et,ot]),nt=t.object({name:t.string().optional(),browser:P.optional(),run:t.enum(["PARALLEL","SEQUENTIAL"]).optional(),initialDelaySeconds:t.number().nonnegative().optional(),workers:t.number().positive().optional(),iterations:t.number().positive().optional(),location:t.string().optional(),steps:t.array(it).optional(),actions:t.array(Z).optional()}),$=t.object({scenarios:t.array(nt)});var E=i=>{let e=k.load(i);return $.parse(e)};var A={debug(i){console.log(i)},info(i){console.log(i)},warn(i){console.log(i)},error(i){console.error(i)}};var w=class{envVarResolve;logger;constructor(e,n=A){this.envVarResolve=o=>{let r=o.startsWith("$")?o.substring(1):o;return e(r)},this.logger=n}resolve(e){return{scenarios:e.scenarios.map((n,o)=>this.resolveScenario(n,o+1))}}resolveScenario(e,n){this.logger.debug(`Resolving YAML scenario: ${JSON.stringify(e)}`);let o=[];e.location&&o.push(this.createNavigateAction(e.location)),e.steps&&o.push(...e.steps.map(s=>this.resolveStep(s))),e.actions&&o.push(...e.actions.map(s=>this.resolveAction(s)));let r=e.run==="SEQUENTIAL"?{delaySecondsBetweenWorkerInits:e.initialDelaySeconds||0}:{initialDelaySeconds:e.initialDelaySeconds||0};return{name:e.name||`Scenario ${n}`,browser:e.browser||"chrome",run:r,workers:e.workers||1,iterations:e.iterations||1,actions:o}}resolveStep(e){if(e==="waitForever")return this.logger.debug("Resolving step: waitForever"),{actionType:"WaitForever"};if("wait"in e)return this.logger.debug(`Resolving step: wait ${e.wait} seconds`),{actionType:"Wait",milliseconds:e.wait*1e3};if("navigate"in e)return this.logger.debug("Resolving step: navigate"),this.createNavigateAction(e.navigate);if("click"in e)return this.logger.debug(`Resolving step: click "${e.click}"`),{actionType:"CLICK",selector:e.click};if("input"in e)return this.logger.debug(`Resolving step: input into "${e.input.selector}"`),{actionType:"TYPE",selector:e.input.selector,text:e.input.text};throw new Error(`Unknown step format: ${JSON.stringify(e)}`)}resolveAction(e){switch(this.logger.debug(`Resolving action: ${JSON.stringify(e)}`),e.type){case"GOTO":return{actionType:"GOTO",url:this.resolveStringOrEnv(e.url),options:e.options};case"WAIT":return{actionType:"Wait",milliseconds:e.milliseconds*1e3};case"CLICK":return{actionType:"CLICK",selector:e.selector,options:e.options};case"TYPE":return{actionType:"TYPE",selector:e.selector,text:e.text,options:e.options};case"WAIT_FOR_SELECTOR":return{actionType:"WAIT_FOR_SELECTOR",selector:e.selector,options:e.options};case"WAIT_FOR_TIMEOUT":return{actionType:"WAIT_FOR_TIMEOUT",timeout:e.timeout};case"PAUSE":return{actionType:"PAUSE"};case"SCREENSHOT":return{actionType:"SCREENSHOT",path:this.resolveStringOrEnv(e.path),options:e.options};case"EVALUATE":return{actionType:"EVALUATE",script:e.script};case"SET_VIEWPORT":return{actionType:"SET_VIEWPORT",width:e.width,height:e.height};case"CLOSE":return{actionType:"CLOSE"};default:return e}}createNavigateAction(e){return{actionType:"Navigate",location:this.resolveUrlOrEnv(e)}}resolveUrlOrEnv(e){let n="[workerIndex]";if(e.startsWith("$")){if(e.endsWith(n)){this.logger.debug(`Resolving indexed environment variable: "${e}"`);let o=e.substring(1,e.length-n.length);return{workerIndex:this.envVarResolve(o).split(",").map(c=>c.trim())}}return this.logger.debug(`Resolving environment variable: "${e}"`),{url:this.envVarResolve(e.substring(1))}}return this.logger.debug(`Using direct URL: "${e}"`),{url:e}}resolveStringOrEnv(e){return e.startsWith("$")?(this.logger.debug(`Resolving environment variable: "${e}"`),this.envVarResolve(e.substring(1))):e}};function st(i,e){let n=e||"png";return i.endsWith(".png")||i.endsWith(".jpeg")||i.endsWith(".jpg")||i.endsWith(".webp")?i:`${i}.${n}`}var W=async(i,e,n=A,o)=>await at(e.workers,e.name,async r=>{for(let s=0;s<e.iterations;s++){let c=e.run;"delaySecondsBetweenWorkerInits"in c?(n.info(`Scenario "${e.name}" (worker ${r}) > Choosing SEQUENTIAL start with ${c.delaySecondsBetweenWorkerInits} seconds between workers`),await new Promise(u=>setTimeout(u,c.delaySecondsBetweenWorkerInits*r*1e3))):(n.info(`Scenario "${e.name}" (worker ${r}) > Choosing PARALLEL start with ${c.initialDelaySeconds} seconds of initial delay`),await new Promise(u=>setTimeout(u,c.initialDelaySeconds*1e3)));let l=await i.newPage();o&&await o(l,{scenarioName:e.name,workerIndex:r,iteration:s}),await ct(e.name,e.actions,l,r,n),await l.close()}},n),at=async(i,e,n,o=A)=>Promise.all(Array.from({length:i},async(r,s)=>{try{o.info(`Scenario "${e}" (worker ${s}) started.`),await n(s)}catch(c){o.error(`Scenario ${e} (worker ${s}) failed: ${c}`)}})),ct=async(i,e,n,o,r=A)=>{for(let s of e){let{actionType:c}=s;switch(c){case"Navigate":let l=s;"url"in l.location?(r.info(`Scenario "${i}" (worker ${o}) > Navigating to location "${l.location.url}"`),await n.goto(l.location.url)):(r.info(`Scenario "${i}" (worker ${o}) > Navigating to indexed location "${l.location.workerIndex[o]}"`),await n.goto(l.location.workerIndex[o]));break;case"Wait":let u=s;r.info(`Scenario "${i}" (worker ${o}) > Waiting ${u.milliseconds} milliseconds...`),await n.evaluate(y=>new Promise(f=>setTimeout(f,y)),u.milliseconds);break;case"WaitForever":for(r.info(`Scenario "${i}" (worker ${o}) > Waiting forever...`);;)await new Promise(y=>setTimeout(y,3600*1e3));break;case"GOTO":let d=s;r.info(`Scenario "${i}" (worker ${o}) > GOTO "${d.url}"`);let v={timeout:d.options?.timeout,waitUntil:d.options?.waitUntil,referer:d.options?.referer};await n.goto(d.url,v);break;case"CLICK":let a=s;r.info(`Scenario "${i}" (worker ${o}) > CLICK on "${a.selector}"`);let g={button:a.options?.button,clickCount:a.options?.clickCount,delay:a.options?.delay};await n.click(a.selector,g);break;case"TYPE":let S=s;r.info(`Scenario "${i}" (worker ${o}) > TYPE into "${S.selector}"`),await n.type(S.selector,S.text,S.options);break;case"WAIT_FOR_SELECTOR":let m=s;r.info(`Scenario "${i}" (worker ${o}) > WAIT_FOR_SELECTOR "${m.selector}"`),await n.waitForSelector(m.selector,m.options);break;case"WAIT_FOR_TIMEOUT":let h=s;r.info(`Scenario "${i}" (worker ${o}) > WAIT_FOR_TIMEOUT ${h.timeout} milliseconds`),await n.evaluate(y=>new Promise(f=>setTimeout(f,y)),h.timeout);break;case"PAUSE":r.info(`Scenario "${i}" (worker ${o}) > PAUSE, press Enter to continue...`),await new Promise(y=>{process.stdin.resume(),process.stdin.once("data",()=>{process.stdin.pause(),y()})});break;case"SCREENSHOT":let p=s;r.info(`Scenario "${i}" (worker ${o}) > SCREENSHOT "${p.path}"`);let O={path:st(p.path,p.options?.type),type:p.options?.type,quality:p.options?.quality,fullPage:p.options?.fullPage,clip:p.options?.clip,omitBackground:p.options?.omitBackground,encoding:p.options?.encoding};await n.screenshot(O);break;case"EVALUATE":let C=s;r.info(`Scenario "${i}" (worker ${o}) > EVALUATE script`),await n.evaluate(C.script);break;case"SET_VIEWPORT":let b=s;r.info(`Scenario "${i}" (worker ${o}) > SET_VIEWPORT width=${b.width}, height=${b.height}`),await n.setViewport({width:b.width,height:b.height});break;case"CLOSE":r.info(`Scenario "${i}" (worker ${o}) > CLOSE page`),await n.close();break;default:throw new Error(`Unhandled action type "${c}" while evaluating scenario "${i}" on worker ${o}`)}}};var re=async(i,e={})=>{let{browsers:n={},logger:o=A,onPage:r}=e,s=E(i),l=new w(a=>{let g=process.env[a];if(o.debug(`Resolving env variable "${a}" with value ***`),g)return g;throw new Error(`Tried to resolve env variable ${a} but was empty.`)},o).resolve(s),u={};for(let a of l.scenarios){let g=a.browser||"chrome";u[g]||(u[g]=[]),u[g]?.push(a)}let d=["chrome","firefox"],v=Object.entries(u).map(async([a,g])=>{if(!g)return;if(!d.includes(a)){console.error(`Navigateur non support\xE9: ${a}. Les navigateurs support\xE9s sont: ${d.join(", ")}`);return}let S=["--no-sandbox","--disable-setuid-sandbox","--disable-dev-shm-usage","--disable-accelerated-2d-canvas","--disable-gpu","--ignore-certificate-errors"];a==="chrome"&&S.push("--start-maximized"),a==="firefox"&&S.push("--private");let m=n[a],h=!1;m||(m=await x.launch({headless:!1,channel:a,acceptInsecureCerts:!0,args:S}),h=!0);try{for(let p of g)await W(m,p,o,r)}catch(p){console.error(`Erreur lors de l'ex\xE9cution des sc\xE9narios pour ${a}:`,p)}finally{h&&m&&await m.close()}});await Promise.all(v)};export{re as readYamlAndInterpret};
1
+ import*as F from"js-yaml";import*as _ from"zod";import*as t from"zod";var y=t.number().int().nonnegative(),v=t.number().int().positive(),$=t.string().describe("CSS selector, or Puppeteer selector: ::-p-xpath(...), ::-p-text(...), ::-p-aria(...), xpath/..., >>> (shadow DOM). ${VAR} placeholders are resolved."),W=t.string().describe('URL. "$VAR" as the whole value, or ${VAR} placeholders anywhere.'),L=t.enum(["load","domcontentloaded","networkidle0","networkidle2"]),M=t.enum(["left","right","middle"]),D=t.enum(["chrome","firefox"]),Z=t.strictObject({timeout:y.optional().describe("Navigation timeout in milliseconds."),waitUntil:t.union([L,L.array()]).optional().describe("When to consider the navigation finished."),referer:t.string().optional().describe("Referer header. ${VAR} placeholders are resolved.")}).optional(),H=t.strictObject({button:M.optional(),clickCount:v.optional().describe("2 for a double-click."),delay:y.optional().describe("Milliseconds between mousedown and mouseup.")}).optional(),K=t.strictObject({delay:y.optional().describe("Milliseconds between keystrokes.")}).optional(),q=t.strictObject({visible:t.boolean().optional(),hidden:t.boolean().optional(),timeout:y.optional().describe("Timeout in milliseconds.")}).optional(),Q=t.strictObject({type:t.enum(["png","jpeg"]).optional(),quality:t.number().int().min(0).max(100).optional().describe("JPEG quality (0-100)."),fullPage:t.boolean().optional(),clip:t.strictObject({x:t.number(),y:t.number(),width:t.number().positive(),height:t.number().positive()}).optional(),omitBackground:t.boolean().optional(),encoding:t.enum(["base64","binary"]).optional()}).optional(),J=t.strictObject({type:t.literal("GOTO"),url:W,options:Z}).describe("Navigate to a URL, with navigation options."),X=t.strictObject({type:t.literal("CLICK"),selector:$,options:H}).describe("Click on an element."),Y=t.strictObject({type:t.literal("TYPE"),selector:$,text:t.string().describe("Text to type. ${VAR} placeholders are resolved (keep credentials in environment variables). Never logged."),options:K}).describe("Type text into an element."),ee=t.strictObject({type:t.literal("WAIT_FOR_SELECTOR"),selector:$,options:q}).describe("Wait for an element to appear (or to be visible / hidden)."),te=t.strictObject({type:t.literal("WAIT_FOR_TIMEOUT"),timeout:y.describe("Milliseconds.")}).describe("Wait for a duration in milliseconds."),oe=t.strictObject({type:t.literal("WAIT"),milliseconds:y}).describe("Wait for a duration in milliseconds."),re=t.strictObject({type:t.literal("PAUSE")}).describe("Wait for Enter in an interactive terminal (debugging). Ignored with a warning when there is none."),ie=t.strictObject({type:t.literal("SCREENSHOT"),path:t.string().describe('File path. "$VAR" as the whole value, or ${VAR} placeholders anywhere.'),options:Q}).describe("Take a screenshot."),ne=t.strictObject({type:t.literal("EVALUATE"),script:t.string().describe("JavaScript evaluated in the page. Never interpolated.")}).describe("Evaluate JavaScript in the page."),se=t.strictObject({type:t.literal("SET_VIEWPORT"),width:v,height:v}).describe("Set the page viewport size."),ae=t.strictObject({type:t.literal("CLOSE")}).describe("Close the page."),P=t.discriminatedUnion("type",[J,X,Y,ee,te,oe,re,ie,ne,se,ae]),ce=t.strictObject({navigate:W}).describe('Navigate to a URL ("$VAR[workerIndex]" gives one URL per worker).'),pe=t.literal("waitForever").describe("Keep the page open forever."),le=t.strictObject({wait:t.number().nonnegative().describe("Seconds.")}).describe("Wait for a duration in seconds."),ue=t.strictObject({click:$}).describe("Click on an element."),de=t.strictObject({input:t.strictObject({selector:$,text:t.string().describe("Text to type. ${VAR} placeholders are resolved (keep credentials in environment variables). Never logged.")})}).describe("Type text into an element."),ge=t.union([ce,pe,le,ue,de,P]),he=t.strictObject({name:t.string().optional().describe('Scenario name (default: "Scenario <n>").'),browser:D.optional().describe("Browser to use (default: chrome)."),run:t.enum(["PARALLEL","SEQUENTIAL"]).optional().describe("Worker start strategy (default: PARALLEL). PARALLEL: all workers start after initialDelaySeconds. SEQUENTIAL: worker n starts after n \xD7 initialDelaySeconds."),initialDelaySeconds:t.number().nonnegative().optional().describe("Start delay, applied once per worker before its first iteration (see run)."),workers:v.optional().describe("Number of parallel workers (default: 1)."),iterations:v.optional().describe("Repetitions per worker (default: 1)."),location:W.optional().describe('Starting URL. "$VAR", ${VAR} placeholders, or "$VAR[workerIndex]" for one URL per worker.'),steps:t.array(ge).optional().describe("Shorthand steps; full actions are accepted too, to mix both syntaxes in order."),actions:t.array(P).optional().describe("Full actions, run after steps.")}).describe("A population of users: its workers run the steps and actions for the given number of iterations."),B=t.strictObject({scenarios:t.array(he).describe("Scenarios, all started at the same time.")});var V=r=>r instanceof Error?r.message:String(r),b=class extends Error{constructor(e,o){super(e,o),this.name="ConfigurationError"}},T=class extends Error{scenarioName;workerIndex;iteration;constructor(e,o,i,n){super(`Scenario "${e}" (worker ${o}, iteration ${i}) failed: ${V(n)}`,{cause:n}),this.name="WorkerError",this.scenarioName=e,this.workerIndex=o,this.iteration=i}},E=class extends Error{browser;constructor(e,o){super(`Failed to launch browser "${e}": ${V(o)}`,{cause:o}),this.name="BrowserLaunchError",this.browser=e}};var I=r=>{let e;try{e=F.load(r)}catch(i){throw new b(`Invalid YAML syntax: ${i instanceof Error?i.message:String(i)}`,{cause:i})}let o=B.safeParse(e);if(!o.success)throw new b(`Invalid scenario configuration:
2
+ ${_.prettifyError(o.error)}`,{cause:o.error});return o.data};var f={debug(r){console.log(r)},info(r){console.log(r)},warn(r){console.log(r)},error(r){console.error(r)}};var we=/^[A-Za-z_][A-Za-z0-9_]*$/,be=/^\$([A-Za-z_][A-Za-z0-9_]*)$/,Se=/^\$([A-Za-z_][A-Za-z0-9_]*)\[workerIndex\]$/,fe=/\$\$\{|\$\{([^}]*)\}/g,U='per-worker values ("$VAR[workerIndex]") are only supported as the whole value of location and navigate',O=class{envVarResolve;logger;constructor(e,o=f){this.envVarResolve=e,this.logger=o}resolve(e){return{scenarios:e.scenarios.map((o,i)=>this.resolveScenario(o,i+1))}}resolveScenario(e,o){let i={name:e.name||`Scenario ${o}`,workers:e.workers||1};this.logger.debug(`Resolving scenario "${i.name}" (${e.steps?.length??0} step(s), ${e.actions?.length??0} action(s))`);let n=[];try{e.location&&n.push(this.createNavigateAction(e.location,i));for(let s of e.steps??[])n.push(this.resolveStep(s,i));for(let s of e.actions??[])n.push(this.resolveAction(s))}catch(s){throw new b(`Scenario "${i.name}": ${s instanceof Error?s.message:String(s)}`,{cause:s})}let a=e.run==="SEQUENTIAL"?{delaySecondsBetweenWorkerInits:e.initialDelaySeconds||0}:{initialDelaySeconds:e.initialDelaySeconds||0};return{name:i.name,browser:e.browser||"chrome",run:a,workers:i.workers,iterations:e.iterations||1,actions:n}}resolveStep(e,o){return e==="waitForever"?{actionType:"WaitForever"}:"type"in e?this.resolveAction(e):"wait"in e?{actionType:"Wait",milliseconds:e.wait*1e3}:"navigate"in e?this.createNavigateAction(e.navigate,o):"click"in e?{actionType:"CLICK",selector:this.interpolate(e.click)}:{actionType:"TYPE",selector:this.interpolate(e.input.selector),text:this.interpolate(e.input.text)}}resolveAction(e){switch(this.logger.debug(`Resolving action: ${e.type}`),e.type){case"GOTO":return{actionType:"GOTO",url:this.resolveWholeValueOrInterpolate(e.url),options:e.options&&{...e.options,referer:e.options.referer&&this.interpolate(e.options.referer)}};case"WAIT":return{actionType:"Wait",milliseconds:e.milliseconds};case"CLICK":return{actionType:"CLICK",selector:this.interpolate(e.selector),options:e.options};case"TYPE":return{actionType:"TYPE",selector:this.interpolate(e.selector),text:this.interpolate(e.text),options:e.options};case"WAIT_FOR_SELECTOR":return{actionType:"WAIT_FOR_SELECTOR",selector:this.interpolate(e.selector),options:e.options};case"WAIT_FOR_TIMEOUT":return{actionType:"WAIT_FOR_TIMEOUT",timeout:e.timeout};case"PAUSE":return{actionType:"PAUSE"};case"SCREENSHOT":return{actionType:"SCREENSHOT",path:this.resolveWholeValueOrInterpolate(e.path),options:e.options};case"EVALUATE":return{actionType:"EVALUATE",script:e.script};case"SET_VIEWPORT":return{actionType:"SET_VIEWPORT",width:e.width,height:e.height};case"CLOSE":return{actionType:"CLOSE"};default:return e}}createNavigateAction(e,o){let i=Se.exec(e);if(i){let n=i[1],a=this.env(n).split(",").map(s=>s.trim());if(a.some(s=>s===""))throw new Error(`environment variable "${n}" (used as "${e}") contains an empty value`);if(a.length<o.workers)throw new Error(`environment variable "${n}" (used as "${e}") provides ${a.length} value(s) for ${o.workers} worker(s)`);return{actionType:"Navigate",location:{workerIndex:a}}}return{actionType:"Navigate",location:{url:this.resolveWholeValueOrInterpolate(e)}}}resolveWholeValueOrInterpolate(e){let o=be.exec(e);if(o)return this.env(o[1]);if(/^\$[A-Za-z_]/.test(e))throw new Error(`unsupported environment variable reference "${e}": use "$VAR" for the whole value or "\${VAR}" inside a value`+(e.endsWith("[workerIndex]")?`; ${U}`:""));return this.interpolate(e)}interpolate(e){return e.replace(fe,(o,i)=>{if(o==="$${")return"${";if(i===void 0||!we.test(i))throw new Error(`invalid placeholder "${o}": expected \${VAR_NAME}`+(i?.endsWith("[workerIndex]")?`; ${U}`:""));return this.env(i)})}env(e){return this.logger.debug(`Resolving environment variable "${e}"`),this.envVarResolve(e)}};import{setTimeout as x}from"node:timers/promises";function Ae(r,e){let o=e||"png";return r.endsWith(".png")||r.endsWith(".jpeg")||r.endsWith(".jpg")||r.endsWith(".webp")?r:`${r}.${o}`}var ye=(r,e)=>"delaySecondsBetweenWorkerInits"in r?r.delaySecondsBetweenWorkerInits*e:r.initialDelaySeconds,j=async(r,e,o=f,i)=>{let n=[],a=async s=>{let p=ye(e.run,s),m="delaySecondsBetweenWorkerInits"in e.run?"SEQUENTIAL":"PARALLEL";o.info(`Scenario "${e.name}" (worker ${s}) started (${m}, starting in ${p}s).`),await x(p*1e3);for(let u=0;u<e.iterations;u++)try{await ve(r,e,s,u,o,i)}catch(S){let g=new T(e.name,s,u,S);o.error(g.message),n.push(g)}};if(await Promise.all(Array.from({length:e.workers},(s,p)=>a(p))),n.length>0){n.sort((p,m)=>p.workerIndex-m.workerIndex||p.iteration-m.iteration);let s=e.workers*e.iterations;throw new AggregateError(n,`Scenario "${e.name}": ${n.length}/${s} iteration(s) failed`)}},ve=async(r,e,o,i,n,a)=>{let s;try{s=await r.newPage(),a&&await a(s,{scenarioName:e.name,workerIndex:o,iteration:i}),await $e(e.name,e.actions,s,o,n)}finally{s&&!s.isClosed()&&await s.close().catch(p=>n.warn(`Scenario "${e.name}" (worker ${o}, iteration ${i}) > failed to close page: ${p}`))}},$e=async(r,e,o,i,n=f)=>{for(let a of e){let{actionType:s}=a;switch(s){case"Navigate":let p=a;"url"in p.location?(n.info(`Scenario "${r}" (worker ${i}) > Navigating to location "${p.location.url}"`),await o.goto(p.location.url)):(n.info(`Scenario "${r}" (worker ${i}) > Navigating to indexed location "${p.location.workerIndex[i]}"`),await o.goto(p.location.workerIndex[i]));break;case"Wait":let m=a;n.info(`Scenario "${r}" (worker ${i}) > Waiting ${m.milliseconds} milliseconds...`),await x(m.milliseconds);break;case"WaitForever":for(n.info(`Scenario "${r}" (worker ${i}) > Waiting forever...`);;)await x(3600*1e3);break;case"GOTO":let u=a;n.info(`Scenario "${r}" (worker ${i}) > GOTO "${u.url}"`);let S={timeout:u.options?.timeout,waitUntil:u.options?.waitUntil,referer:u.options?.referer};await o.goto(u.url,S);break;case"CLICK":let g=a;n.info(`Scenario "${r}" (worker ${i}) > CLICK on "${g.selector}"`);let C={button:g.options?.button,clickCount:g.options?.clickCount,delay:g.options?.delay};await o.click(g.selector,C);break;case"TYPE":let w=a;n.info(`Scenario "${r}" (worker ${i}) > TYPE into "${w.selector}"`),await o.type(w.selector,w.text,w.options);break;case"WAIT_FOR_SELECTOR":let c=a;n.info(`Scenario "${r}" (worker ${i}) > WAIT_FOR_SELECTOR "${c.selector}"`),await o.waitForSelector(c.selector,c.options);break;case"WAIT_FOR_TIMEOUT":let d=a;n.info(`Scenario "${r}" (worker ${i}) > WAIT_FOR_TIMEOUT ${d.timeout} milliseconds`),await x(d.timeout);break;case"PAUSE":if(!process.stdin.isTTY){n.warn(`Scenario "${r}" (worker ${i}) > PAUSE ignored: no interactive terminal`);break}n.info(`Scenario "${r}" (worker ${i}) > PAUSE, press Enter to continue...`),await new Promise(A=>{process.stdin.resume(),process.stdin.once("data",()=>{process.stdin.pause(),A()})});break;case"SCREENSHOT":let l=a;n.info(`Scenario "${r}" (worker ${i}) > SCREENSHOT "${l.path}"`);let R={path:Ae(l.path,l.options?.type),type:l.options?.type,quality:l.options?.quality,fullPage:l.options?.fullPage,clip:l.options?.clip,omitBackground:l.options?.omitBackground,encoding:l.options?.encoding};await o.screenshot(R);break;case"EVALUATE":let k=a;n.info(`Scenario "${r}" (worker ${i}) > EVALUATE script`),await o.evaluate(k.script);break;case"SET_VIEWPORT":let h=a;n.info(`Scenario "${r}" (worker ${i}) > SET_VIEWPORT width=${h.width}, height=${h.height}`),await o.setViewport({width:h.width,height:h.height});break;case"CLOSE":n.info(`Scenario "${r}" (worker ${i}) > CLOSE page`),await o.close();break;default:throw new Error(`Unhandled action type "${s}" while evaluating scenario "${r}" on worker ${i}`)}}};import*as N from"puppeteer";var Te=(r,e)=>{if(r==="firefox")return[];let o=["--no-sandbox","--disable-setuid-sandbox","--disable-dev-shm-usage","--disable-accelerated-2d-canvas","--disable-gpu","--ignore-certificate-errors"];return e||o.push("--start-maximized"),o},Ee=(r,e,o={})=>({browser:r,headless:e,acceptInsecureCerts:!0,...o,args:[...Te(r,e),...o.args??[]]}),G=(r,e,o)=>N.launch(Ee(r,e,o));var bt=async(r,e={})=>{let{browsers:o={},logger:i=f,onPage:n,headless:a=!0,launchOptions:s={}}=e,p=I(r),u=new O(c=>{let d=process.env[c];if(d)return d;throw new Error(`environment variable "${c}" is not set (or empty)`)},i).resolve(p),S=new Map;for(let c of u.scenarios){let d=S.get(c.browser)??[];d.push(c),S.set(c.browser,d)}let g=async(c,d)=>{let l=o[c],R=!l;if(!l)try{i.info(`Launching ${c} (headless: ${a})`),l=await G(c,a,s[c])}catch(h){let A=new E(c,h);return i.error(A.message),[A]}let k=l;try{return(await Promise.all(d.map(async A=>{try{return await j(k,A,i,n),[]}catch(z){return z instanceof AggregateError?z.errors:[z]}}))).flat()}finally{R&&await k.close().catch(h=>i.warn(`Failed to close ${c}: ${h}`))}},w=(await Promise.all([...S].map(([c,d])=>g(c,d)))).flat();if(w.length>0)throw new AggregateError(w,`${w.length} error(s) occurred while running scenarios`)};export{E as BrowserLaunchError,b as ConfigurationError,T as WorkerError,bt as readYamlAndInterpret};
@@ -1,4 +1,10 @@
1
1
  import * as p from 'puppeteer';
2
- import { YpLogger } from '../core/logger';
3
- import { OnPageCallback, Scenario } from '../core/model';
4
- export declare const evalScenario: (browser: p.Browser, scenario: Scenario, logger?: YpLogger, onPage?: OnPageCallback) => Promise<void[]>;
2
+ import { YpLogger } from '../core/logger.js';
3
+ import { OnPageCallback, Scenario } from '../core/model.js';
4
+ /**
5
+ * Runs every worker of the scenario, each one running all its iterations.
6
+ * A failing iteration interrupts neither the next iterations of the worker
7
+ * nor the other workers. Once everything is done, the promise rejects with an
8
+ * `AggregateError` of `WorkerError` (one per failed iteration) if anything failed.
9
+ */
10
+ export declare const evalScenario: (browser: p.Browser, scenario: Scenario, logger?: YpLogger, onPage?: OnPageCallback) => Promise<void>;
@@ -0,0 +1,9 @@
1
+ import * as p from 'puppeteer';
2
+ import { SupportedBrowser } from '../core/model.js';
3
+ /**
4
+ * Builds Puppeteer launch options for the given browser. The browser is
5
+ * selected with `browser` (not `channel`, which only accepts Chrome release
6
+ * channels). User options override defaults; user `args` are appended.
7
+ */
8
+ export declare const buildLaunchOptions: (browser: SupportedBrowser, headless: boolean, extra?: p.LaunchOptions) => p.LaunchOptions;
9
+ export declare const launchBrowser: (browser: SupportedBrowser, headless: boolean, extra?: p.LaunchOptions) => Promise<p.Browser>;