@epinova/opticli 0.27.0-preview → 0.28.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
@@ -4,16 +4,14 @@ Typed access to [opticli](https://github.com/epinova/opticli) from Node.js, and
4
4
  an Optimizely CMS 12 or 13 site, seed the content the tests need, give each test content of its own, and clean up
5
5
  after. Writes go through the CMS (versions, cache, events), so a page shows a change at once.
6
6
 
7
- **Preview.** The API may still change: install it with the `preview` tag. Its version is the opticli version it was
8
- released with, plus `-preview`.
9
-
10
7
  ## Install
11
8
 
12
9
  ```bash
13
- npm install -D @epinova/opticli@preview
10
+ npm install -D @epinova/opticli
14
11
  ```
15
12
 
16
- The package runs the opticli CLI; it doesn't install it. It needs the opticli it was released with, or newer: pin it in
13
+ The package runs the opticli CLI; it doesn't install it. Its version is the opticli version it was released with, and it
14
+ needs that opticli or newer: pin it in
17
15
  the site's repository with a [local tool manifest](https://learn.microsoft.com/dotnet/core/tools/local-tools-how-to-use),
18
16
  so CI gets the same version:
19
17
 
@@ -120,7 +118,7 @@ content stays for the next run.
120
118
  | Fixture | Scope | |
121
119
  |---|---|---|
122
120
  | `seed` | worker | `refs` and `urls` (host-relative) of the plan's steps, by `id`; `baseURL`. |
123
- | `cms` | test | Writes undone after the test: `page(type, name, properties?, { parent?, publish? })` (published by default; returns `ref`, `version` and `url`), `set`, `publish`, `apply`. What it creates goes to the recycle bin after the test; changes to seeded content are repaired by the next run's seed. |
121
+ | `cms` | test | Content for one test: `page(type, name, properties?, { parent?, publish?, keep? })` (published by default; returns `ref`, `version` and `url`), `set`, `publish`, `apply(plan, { keep?, ... })`. What `page` and `apply` create goes to the recycle bin after the test, unless `keep`. |
124
122
  | `sandbox` | worker | With the `opticliSandbox` option: the ref of the worker's own page, where `cms.page()` creates content. It goes to the recycle bin when the worker ends, with anything a failed test left. |
125
123
  | `opticli` | worker | The `OptiCli` client, for anything else. |
126
124
  | `baseURL` | test | The site `opticli serve` runs. |
@@ -128,6 +126,20 @@ content stays for the next run.
128
126
  `opticliSandbox`: `parent` (a ref, or `$id` of a seed step), `type` (a page type the parent allows and that allows your
129
127
  test pages), `properties`. Without it, pass `{ parent }` to `cms.page()`.
130
128
 
129
+ ### What stays, and what goes
130
+
131
+ | | After the test run |
132
+ |---|---|
133
+ | The seed plan | Stays. The next run repairs what changed. |
134
+ | `cms.page()`, `cms.apply()` | Go after the test. With `keep: true`, stay. |
135
+ | `cms.set()`, `cms.publish()` | Stay: they change content the test didn't make. On seeded content the next run's seed puts it back, but tests in other workers of the same run see the change. |
136
+ | The sandbox | Goes when the worker ends, with everything below it, whatever made it. |
137
+ | The `opticli` fixture, the Node.js client | Stay: they never remove anything (except below the sandbox, as above). |
138
+
139
+ `keep: true` needs a parent outside the sandbox and outside content the test made, since that goes with everything
140
+ below it; `cms` refuses it there before saving anything. A step of `cms.apply()` that found its content already there
141
+ (`updateExisting`) leaves it, with or without `keep`.
142
+
131
143
  Deleted content goes to the recycle bin, as in the CMS: nothing is deleted for good. On a local database that is
132
144
  usually fine; empty the recycle bin in the CMS now and then.
133
145
 
@@ -147,6 +159,7 @@ try {
147
159
  }
148
160
  ```
149
161
 
162
+ The client changes what you tell it to and removes nothing on its own, so it suits scripts that make content to keep.
150
163
  Typed methods: `serve`, `serveStatus`, `serveStop`, `get`, `url`, `create`, `set`, `publish`, `delete`, `apply`.
151
164
  `run(args)` runs any other command and returns its JSON envelope. A failure throws `OptiCliError` with opticli's `code`,
152
165
  `exitCode`, `hint`, `details` and `version`; see opticli's
@@ -160,7 +173,7 @@ changes in one call.
160
173
  | | |
161
174
  |---|---|
162
175
  | `command` option or `OPTICLI` | The opticli to run: an executable, or a checkout's `opticli.dll` (run with `dotnet`). Otherwise `dotnet tool run opticli` when a tool manifest above the working directory has it, else `opticli` on PATH. |
163
- | `project` option or `OPTICLI_PROJECT` | The site's `.csproj` or folder. Otherwise opticli finds it from the working directory. |
176
+ | `project` option or `OPTICLI_PROJECT` | The site's `.csproj` or folder, relative to `cwd` (default: the working directory). Otherwise opticli finds it from the working directory. |
164
177
 
165
178
  `apply` runs in `baseDir` (`planDir` in setup), so a tool manifest must be in or above that folder too.
166
179
 
package/lib/opticli.d.ts CHANGED
@@ -6,7 +6,10 @@ export interface OptiCliOptions {
6
6
  * working directory has opticli, else `opticli` on PATH.
7
7
  */
8
8
  command?: readonly string[];
9
- /** The site's project (`--project`): its `.csproj` or folder. Default: `$OPTICLI_PROJECT`, else opticli's discovery from the working directory. */
9
+ /**
10
+ * The site's project (`--project`): its `.csproj` or folder, relative to `cwd`. Default: `$OPTICLI_PROJECT`, else
11
+ * opticli's discovery from the working directory.
12
+ */
10
13
  project?: string;
11
14
  /** Working directory for every call (except `apply`'s `baseDir`). Default: the process's. */
12
15
  cwd?: string;
package/lib/opticli.js CHANGED
@@ -32,7 +32,9 @@ export class OptiCli {
32
32
  constructor(options = {}) {
33
33
  this.cwd = options.cwd;
34
34
  this.command = options.command ?? resolveCommand(options.cwd ?? process.cwd());
35
- this.project = options.project ?? (process.env.OPTICLI_PROJECT || undefined);
35
+ // Absolute: opticli resolves --project against its working directory, and apply runs in the plan's folder.
36
+ const project = options.project ?? (process.env.OPTICLI_PROJECT || undefined);
37
+ this.project = project ? resolve(options.cwd ?? process.cwd(), project) : undefined;
36
38
  this.onWarnings = options.onWarnings;
37
39
  }
38
40
  /** Runs any opticli command with `--json` and returns its envelope; throws OptiCliError when `ok` is false. */
@@ -138,20 +140,32 @@ export class OptiCli {
138
140
  }
139
141
  exec(args, options) {
140
142
  const [program, ...prefix] = this.command;
143
+ const timeoutMs = options.timeoutMs ?? 120_000;
141
144
  return new Promise((resolvePromise, reject) => {
142
- const child = spawn(program, [...prefix, ...args], {
143
- cwd: options.cwd ?? this.cwd,
144
- stdio: ['pipe', 'pipe', 'pipe'],
145
- timeout: options.timeoutMs ?? 120_000,
146
- });
145
+ // The timer is ours, not spawn's `timeout`: spawn's outlives a program that never started, and keeps Node running.
146
+ let timer;
147
+ let settled = false;
148
+ const settle = (done) => {
149
+ if (settled)
150
+ return;
151
+ settled = true;
152
+ clearTimeout(timer);
153
+ done();
154
+ };
155
+ const child = spawn(program, [...prefix, ...args], { cwd: options.cwd ?? this.cwd, stdio: ['pipe', 'pipe', 'pipe'] });
156
+ timer = setTimeout(() => {
157
+ child.kill();
158
+ const message = `no answer within ${timeoutMs / 1000} s; it was stopped`;
159
+ settle(() => reject(new OptiCliError({ code: 'timeout', message, hint: 'For a command that takes longer, pass a longer timeoutMs to run().' }, null, args)));
160
+ }, timeoutMs);
147
161
  let stdout = '';
148
162
  let stderr = '';
149
163
  child.stdout.setEncoding('utf8').on('data', (chunk) => (stdout += chunk));
150
164
  child.stderr.setEncoding('utf8').on('data', (chunk) => (stderr += chunk));
151
- child.on('error', (error) => reject(error.code === 'ENOENT'
165
+ child.on('error', (error) => settle(() => reject(error.code === 'ENOENT'
152
166
  ? new Error(`Couldn't run ${this.command.join(' ')}: install opticli (dotnet tool install -g OptiCli), or set OPTICLI to it.`, { cause: error })
153
- : error));
154
- child.on('close', (exitCode) => resolvePromise({ stdout, stderr, exitCode }));
167
+ : error)));
168
+ child.on('close', (exitCode) => settle(() => resolvePromise({ stdout, stderr, exitCode })));
155
169
  child.stdin.on('error', () => { }); // a CLI that exits before reading stdin
156
170
  child.stdin.end(options.input ?? '');
157
171
  });
@@ -18,7 +18,15 @@ export interface CreatedPage extends WriteResult {
18
18
  /** The page's URL (host-relative), for `page.goto`; for a draft, where it will be once published. */
19
19
  url: string;
20
20
  }
21
- /** Writes that a test's content gets, undone after the test. */
21
+ /** Leave the content on the site after the test: `cms.page(..., { keep: true, parent })`. */
22
+ export interface KeepOption {
23
+ /**
24
+ * Don't remove what this call creates after the test. It needs a parent outside the worker's sandbox and outside
25
+ * content the test made, which both go after the test (or worker) with everything below them.
26
+ */
27
+ keep?: boolean;
28
+ }
29
+ /** Writes that a test's content gets, undone after the test unless `keep`. */
22
30
  export declare class Cms {
23
31
  readonly opticli: OptiCli;
24
32
  /** Where {@link page} creates content unless given a parent: the worker's sandbox. */
@@ -27,19 +35,24 @@ export declare class Cms {
27
35
  constructor(opticli: OptiCli,
28
36
  /** Where {@link page} creates content unless given a parent: the worker's sandbox. */
29
37
  parent: ContentRef | undefined);
30
- /** Creates a page, published unless `publish: false`, and removes it after the test. */
31
- page(type: string, name: string, properties?: Properties, options?: WriteOptions & {
38
+ /** Creates a page, published unless `publish: false`, and removes it after the test unless `keep`. */
39
+ page(type: string, name: string, properties?: Properties, { parent: given, keep, ...options }?: WriteOptions & KeepOption & {
32
40
  parent?: ContentRef;
33
41
  }): Promise<CreatedPage>;
34
42
  /** Saves a draft of the change unless `publish`. On seeded content, the next run's seed puts it back. */
35
43
  set(ref: ContentRef, properties: Properties, options?: WriteOptions): Promise<WriteResult>;
36
44
  /** Publishes the latest version, or the version `ref` names (`123_456`). */
37
45
  publish(ref: ContentRef): Promise<WriteResult>;
38
- /** Runs a plan; what it creates below existing content is removed after the test. */
39
- apply(plan: Plan, options?: Parameters<OptiCli['apply']>[1]): Promise<ApplyResult>;
46
+ /**
47
+ * Runs a plan; what it creates is removed after the test unless `keep`. Content that was there before (a step
48
+ * that found it with `updateExisting`) stays.
49
+ */
50
+ apply(plan: Plan, { keep, ...options }?: Parameters<OptiCli['apply']>[1] & KeepOption): Promise<ApplyResult>;
40
51
  /** Deletes what the test made, newest first. Content below other tracked content goes with it. */
41
52
  cleanup(): Promise<void>;
42
53
  private track;
54
+ /** Refuses `keep` below content that goes after the test or worker, before anything is saved. */
55
+ private checkKeep;
43
56
  }
44
57
  type TestFixtures = {
45
58
  cms: Cms;
@@ -8,7 +8,7 @@
8
8
  import { test as base } from '@playwright/test';
9
9
  import { OptiCli, OptiCliError } from "../opticli.js";
10
10
  import { readSeedState } from "./setup.js";
11
- /** Writes that a test's content gets, undone after the test. */
11
+ /** Writes that a test's content gets, undone after the test unless `keep`. */
12
12
  export class Cms {
13
13
  opticli;
14
14
  parent;
@@ -19,13 +19,16 @@ export class Cms {
19
19
  this.opticli = opticli;
20
20
  this.parent = parent;
21
21
  }
22
- /** Creates a page, published unless `publish: false`, and removes it after the test. */
23
- async page(type, name, properties = {}, options = {}) {
24
- const parent = options.parent ?? this.parent;
22
+ /** Creates a page, published unless `publish: false`, and removes it after the test unless `keep`. */
23
+ async page(type, name, properties = {}, { parent: given, keep, ...options } = {}) {
24
+ const parent = given ?? this.parent;
25
25
  if (!parent)
26
26
  throw new Error('cms.page() needs a parent: pass { parent }, or set the opticliSandbox option in playwright.config.');
27
+ if (keep)
28
+ this.checkKeep('cms.page()', [parent]);
27
29
  const result = await this.opticli.create(parent, type, name, properties, { ...options, publish: options.publish ?? true });
28
- this.track(result.ref, parent);
30
+ if (!keep)
31
+ this.track(result.ref, parent);
29
32
  if (!result.url)
30
33
  throw new Error(`${type} ${result.ref} has no URL: is ${type} a page type with a template?`);
31
34
  return result;
@@ -38,12 +41,22 @@ export class Cms {
38
41
  publish(ref) {
39
42
  return this.opticli.publish(ref);
40
43
  }
41
- /** Runs a plan; what it creates below existing content is removed after the test. */
42
- async apply(plan, options = {}) {
44
+ /**
45
+ * Runs a plan; what it creates is removed after the test unless `keep`. Content that was there before (a step
46
+ * that found it with `updateExisting`) stays.
47
+ */
48
+ async apply(plan, { keep, ...options } = {}) {
49
+ if (keep) {
50
+ const parents = plan.operations.map((step) => (isCreate(step) ? (step.parent ?? step.for) : undefined));
51
+ this.checkKeep('cms.apply()', parents.filter((p) => p !== undefined));
52
+ }
43
53
  const result = await this.opticli.apply(plan, options);
44
- for (const step of plan.operations) {
45
- if ((step.op === 'create' || step.op === 'block' || step.op === 'upload') && step.id && result.created[step.id]) {
46
- this.track(result.created[step.id], step.parent ?? step.for);
54
+ if (keep)
55
+ return result;
56
+ for (const step of result.operations) {
57
+ const planned = plan.operations[step.index];
58
+ if (step.id && result.created[step.id] && isCreate(planned) && !step.result?.existing) {
59
+ this.track(result.created[step.id], planned.parent ?? planned.for);
47
60
  }
48
61
  }
49
62
  return result;
@@ -70,6 +83,20 @@ export class Cms {
70
83
  return;
71
84
  this.created.push(ref);
72
85
  }
86
+ /** Refuses `keep` below content that goes after the test or worker, before anything is saved. */
87
+ checkKeep(call, parents) {
88
+ for (const parent of parents) {
89
+ if (parent === this.parent) {
90
+ throw new Error(`${call} with keep: true needs a parent outside the worker's sandbox, which goes when the worker ends.`);
91
+ }
92
+ if (this.created.includes(parent)) {
93
+ throw new Error(`${call} with keep: true can't go below ${parent}, which this test made and which goes after it.`);
94
+ }
95
+ }
96
+ }
97
+ }
98
+ function isCreate(step) {
99
+ return step?.op === 'create' || step?.op === 'block' || step?.op === 'upload';
73
100
  }
74
101
  /**
75
102
  * The fixtures, with `seed.refs` and `seed.urls` typed by the seed plan's step ids:
@@ -1,5 +1,5 @@
1
1
  export { Cms, createTest } from './fixtures.ts';
2
- export type { CreatedPage, OptiCliWorkerOptions, SandboxOptions } from './fixtures.ts';
2
+ export type { CreatedPage, KeepOption, OptiCliWorkerOptions, SandboxOptions } from './fixtures.ts';
3
3
  export { defineSetup, readSeedState, stateVariable } from './setup.ts';
4
4
  export type { SeedState, SetupOptions } from './setup.ts';
5
5
  export { OptiCliError } from '../opticli.ts';
@@ -12,7 +12,7 @@ export interface SetupOptions {
12
12
  publish?: boolean;
13
13
  /** Build the site before starting it (`serve --build`). Default: `OPTICLI_BUILD=1`. */
14
14
  build?: boolean;
15
- /** Leave a site that setup started running after the tests. Default: `OPTICLI_KEEP_SITE=1`. */
15
+ /** Leave a site that setup started running after the tests, or after setup failed. Default: `OPTICLI_KEEP_SITE=1`. */
16
16
  keepSite?: boolean;
17
17
  /** How to run opticli, and which project. */
18
18
  opticli?: Pick<OptiCliOptions, 'command' | 'project' | 'cwd'>;
@@ -22,39 +22,51 @@ export function defineSetup(options = {}) {
22
22
  onWarnings: (warnings, args) => console.warn(`opticli ${args[0]}: ${warnings.join(' ')}`),
23
23
  });
24
24
  const before = await opticli.serveStatus();
25
- const served = await opticli.serve({ build: options.build ?? process.env.OPTICLI_BUILD === '1' });
26
- checkVersions(served.meta.version, served.data.agent?.version);
27
- const baseURL = served.data.browseUrl ?? served.data.url;
28
- if (!baseURL)
29
- throw new Error(`opticli serve gave no URL: ${JSON.stringify(served.data)}`);
30
- const state = {
31
- baseURL,
32
- opticli: { command: opticli.command, project: opticli.project, cwd: opticli.cwd },
33
- refs: {},
34
- urls: {},
35
- };
36
- if (options.plan) {
37
- const applied = await opticli.apply(options.plan, {
38
- baseDir: options.planDir,
39
- updateExisting: true,
40
- publish: options.publish ?? true,
41
- });
42
- state.refs = applied.created;
43
- for (const step of applied.operations) {
44
- if (step.id && step.result?.url)
45
- state.urls[step.id] = step.result.url;
46
- }
47
- const changed = applied.operations.filter((o) => o.status !== 'unchanged').length;
48
- console.log(`opticli: seed plan ${changed ? `changed ${changed} of ${applied.operations.length} steps` : 'unchanged'}`);
49
- }
50
- process.env[stateVariable] = JSON.stringify(state);
51
25
  const startedSite = before.state !== 'running';
52
- return async () => {
53
- if (startedSite && !(options.keepSite ?? process.env.OPTICLI_KEEP_SITE === '1'))
26
+ const keepSite = options.keepSite ?? process.env.OPTICLI_KEEP_SITE === '1';
27
+ const teardown = async () => {
28
+ if (startedSite && !keepSite)
54
29
  await opticli.serveStop();
55
30
  };
31
+ const served = await opticli.serve({ build: options.build ?? process.env.OPTICLI_BUILD === '1' });
32
+ try {
33
+ process.env[stateVariable] = JSON.stringify(await seed(opticli, served, options));
34
+ }
35
+ catch (error) {
36
+ // Playwright runs no teardown when setup fails: stop the site setup started here.
37
+ await teardown().catch((stopError) => console.warn(`opticli: couldn't stop the site: ${stopError}`));
38
+ throw error;
39
+ }
40
+ return teardown;
56
41
  };
57
42
  }
43
+ async function seed(opticli, served, options) {
44
+ checkVersions(served.meta.version, served.data.agent?.version);
45
+ const baseURL = served.data.browseUrl ?? served.data.url;
46
+ if (!baseURL)
47
+ throw new Error(`opticli serve gave no URL: ${JSON.stringify(served.data)}`);
48
+ const state = {
49
+ baseURL,
50
+ opticli: { command: opticli.command, project: opticli.project, cwd: opticli.cwd },
51
+ refs: {},
52
+ urls: {},
53
+ };
54
+ if (options.plan) {
55
+ const applied = await opticli.apply(options.plan, {
56
+ baseDir: options.planDir,
57
+ updateExisting: true,
58
+ publish: options.publish ?? true,
59
+ });
60
+ state.refs = applied.created;
61
+ for (const step of applied.operations) {
62
+ if (step.id && step.result?.url)
63
+ state.urls[step.id] = step.result.url;
64
+ }
65
+ const changed = applied.operations.filter((o) => o.status !== 'unchanged').length;
66
+ console.log(`opticli: seed plan ${changed ? `changed ${changed} of ${applied.operations.length} steps` : 'unchanged'}`);
67
+ }
68
+ return state;
69
+ }
58
70
  /** The workers' copy of what setup did. */
59
71
  export function readSeedState() {
60
72
  const json = process.env[stateVariable];
package/lib/version.d.ts CHANGED
@@ -1,4 +1,4 @@
1
- /** This package's version, which build.mjs sets: opticli's version, with `-preview` while the API may still change. */
1
+ /** This package's version, which build.mjs sets: opticli's version. */
2
2
  export declare const version = "0.0.0-dev";
3
3
  /** The oldest opticli this package works with: the one released with it. */
4
4
  export declare const requiredOpticli: string;
package/lib/version.js CHANGED
@@ -1,4 +1,4 @@
1
- /** This package's version, which build.mjs sets: opticli's version, with `-preview` while the API may still change. */
2
- export const version = '0.27.0-preview';
1
+ /** This package's version, which build.mjs sets: opticli's version. */
2
+ export const version = '0.28.0';
3
3
  /** The oldest opticli this package works with: the one released with it. */
4
4
  export const requiredOpticli = version.split('-')[0];
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@epinova/opticli",
3
- "version": "0.27.0-preview",
3
+ "version": "0.28.0",
4
4
  "description": "Typed Node API and Playwright fixtures for opticli: start an Optimizely CMS site, seed test content and clean up after the tests",
5
5
  "license": "MPL-2.0",
6
6
  "type": "module",