@epinova/opticli 0.0.0-stage → 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/lib/opticli.js ADDED
@@ -0,0 +1,222 @@
1
+ // A typed wrapper around the opticli CLI. Every call runs `opticli <command> --json`, parses the JSON envelope it prints
2
+ // on stdout, and returns `data`, or throws an OptiCliError for `ok: false`. What opticli checks (the local database, drift,
3
+ // property names, plans) stays in the CLI: this module only runs it.
4
+ import { spawn } from 'node:child_process';
5
+ import { existsSync, readFileSync } from 'node:fs';
6
+ import { dirname, join, resolve } from 'node:path';
7
+ /** opticli answered `ok: false`, or didn't answer with JSON (`code` `internal`). */
8
+ export class OptiCliError extends Error {
9
+ code;
10
+ hint;
11
+ details;
12
+ exitCode;
13
+ /** The opticli version that failed, when it says (0.27.0 and newer). */
14
+ version;
15
+ args;
16
+ constructor(error, exitCode, args, version) {
17
+ super(`opticli ${args[0] ?? ''}: ${error.code}: ${error.message}${error.hint ? `\nHint: ${error.hint}` : ''}`);
18
+ this.name = 'OptiCliError';
19
+ this.code = error.code;
20
+ this.hint = error.hint;
21
+ this.details = error.details;
22
+ this.exitCode = exitCode;
23
+ this.version = version;
24
+ this.args = args;
25
+ }
26
+ }
27
+ export class OptiCli {
28
+ command;
29
+ project;
30
+ cwd;
31
+ onWarnings;
32
+ constructor(options = {}) {
33
+ this.cwd = options.cwd;
34
+ this.command = options.command ?? resolveCommand(options.cwd ?? process.cwd());
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;
38
+ this.onWarnings = options.onWarnings;
39
+ }
40
+ /** Runs any opticli command with `--json` and returns its envelope; throws OptiCliError when `ok` is false. */
41
+ async run(args, options = {}) {
42
+ const full = [...args, '--json', ...(this.project ? ['--project', this.project] : [])];
43
+ const { stdout, stderr, exitCode } = await this.exec(full, options);
44
+ let envelope;
45
+ try {
46
+ envelope = JSON.parse(stdout);
47
+ }
48
+ catch {
49
+ const output = (stderr || stdout).trim().slice(0, 2000);
50
+ throw new OptiCliError({ code: 'internal', message: `no JSON on stdout (exit ${exitCode}): ${output}` }, exitCode, args);
51
+ }
52
+ if (!envelope.ok)
53
+ throw new OptiCliError(envelope.error, exitCode, args, envelope.meta?.version);
54
+ if (envelope.meta?.warnings?.length)
55
+ this.onWarnings?.(envelope.meta.warnings, args);
56
+ return envelope;
57
+ }
58
+ async data(args, options) {
59
+ return (await this.run(args, options)).data;
60
+ }
61
+ // serve
62
+ /** Starts the site with the site agent in the background (`serve`), or returns the one that runs. */
63
+ serve(options = {}) {
64
+ const timeout = options.timeoutSeconds ?? 180;
65
+ const args = ['serve', '--timeout', String(timeout)];
66
+ if (options.build)
67
+ args.push('--build');
68
+ if (options.port)
69
+ args.push('--port', String(options.port));
70
+ if (options.https)
71
+ args.push('--https');
72
+ // A build comes first, and may take minutes.
73
+ return this.run(args, { timeoutMs: (timeout + 600) * 1000 });
74
+ }
75
+ serveStatus() {
76
+ return this.data(['serve', '--status']);
77
+ }
78
+ serveStop() {
79
+ return this.data(['serve', '--stop']);
80
+ }
81
+ // reads
82
+ get(ref, options = {}) {
83
+ const args = ['get', ref];
84
+ if (options.lang)
85
+ args.push('--lang', options.lang);
86
+ if (options.version)
87
+ args.push('--version', options.version);
88
+ if (options.full)
89
+ args.push('--full');
90
+ return this.data(args);
91
+ }
92
+ /** Every language branch's path and absolute URL. Write results have `url` already. */
93
+ url(ref) {
94
+ return this.data(['url', ref]);
95
+ }
96
+ // writes
97
+ create(parent, type, name, properties = {}, options = {}) {
98
+ return this.data(['create', parent, '--type', type, '--name', name, ...values(properties), ...writeFlags(options)]);
99
+ }
100
+ /** Saves a draft of the change, unless `publish`. */
101
+ set(ref, properties, options = {}) {
102
+ const args = ['set', ref, ...values(properties), ...writeFlags(options)];
103
+ if (options.name)
104
+ args.push('--name', options.name);
105
+ if (options.from)
106
+ args.push('--from', options.from);
107
+ return this.data(args);
108
+ }
109
+ /** Publishes the latest version, or the version `ref` names (`123_456`). */
110
+ publish(ref, options = {}) {
111
+ const args = ['publish', ref];
112
+ if (options.includeDraft)
113
+ args.push('--include-draft');
114
+ if (options.dryRun)
115
+ args.push('--dry-run');
116
+ return this.data(args);
117
+ }
118
+ /** Moves content, with everything below it, to the recycle bin. */
119
+ delete(ref, options = {}) {
120
+ const args = ['delete', ref];
121
+ if (options.ignoreReferences)
122
+ args.push('--ignore-references');
123
+ if (options.dryRun)
124
+ args.push('--dry-run');
125
+ return this.data(args);
126
+ }
127
+ /**
128
+ * Runs a plan, sent on stdin. Its files (`upload`, `"@file"` values) are relative to `baseDir` and must be inside it.
129
+ * With the plan's `guidNamespace` and `updateExisting`, running it again only changes what differs.
130
+ */
131
+ apply(plan, options = {}) {
132
+ const args = ['apply', '-'];
133
+ if (options.updateExisting)
134
+ args.push('--update-existing');
135
+ if (options.publish)
136
+ args.push('--publish');
137
+ if (options.dryRun)
138
+ args.push('--dry-run');
139
+ return this.data(args, { input: JSON.stringify(plan), cwd: options.baseDir, timeoutMs: 600_000 });
140
+ }
141
+ exec(args, options) {
142
+ const [program, ...prefix] = this.command;
143
+ const timeoutMs = options.timeoutMs ?? 120_000;
144
+ return new Promise((resolvePromise, reject) => {
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);
161
+ let stdout = '';
162
+ let stderr = '';
163
+ child.stdout.setEncoding('utf8').on('data', (chunk) => (stdout += chunk));
164
+ child.stderr.setEncoding('utf8').on('data', (chunk) => (stderr += chunk));
165
+ child.on('error', (error) => settle(() => reject(error.code === 'ENOENT'
166
+ ? new Error(`Couldn't run ${this.command.join(' ')}: install opticli (dotnet tool install -g OptiCli), or set OPTICLI to it.`, { cause: error })
167
+ : error)));
168
+ child.on('close', (exitCode) => settle(() => resolvePromise({ stdout, stderr, exitCode })));
169
+ child.stdin.on('error', () => { }); // a CLI that exits before reading stdin
170
+ child.stdin.end(options.input ?? '');
171
+ });
172
+ }
173
+ }
174
+ /** The command that runs opticli from `cwd`: see {@link OptiCliOptions.command}. */
175
+ export function resolveCommand(cwd, env = process.env) {
176
+ const bin = env.OPTICLI;
177
+ if (bin)
178
+ return bin.endsWith('.dll') ? ['dotnet', bin] : [bin];
179
+ return findToolManifest(cwd) ? ['dotnet', 'tool', 'run', 'opticli'] : ['opticli'];
180
+ }
181
+ /** The nearest local tool manifest above `cwd` that has opticli, as `dotnet tool run` finds it. */
182
+ export function findToolManifest(cwd) {
183
+ for (let dir = resolve(cwd);; dir = dirname(dir)) {
184
+ for (const file of [join(dir, '.config', 'dotnet-tools.json'), join(dir, 'dotnet-tools.json')]) {
185
+ if (!existsSync(file))
186
+ continue;
187
+ try {
188
+ const tools = JSON.parse(readFileSync(file, 'utf8')).tools ?? {};
189
+ if (Object.values(tools).some((tool) => tool.commands?.includes('opticli')))
190
+ return file;
191
+ }
192
+ catch {
193
+ // Not JSON: dotnet would fail on it too. Keep looking above.
194
+ }
195
+ }
196
+ if (dirname(dir) === dir)
197
+ return undefined;
198
+ }
199
+ }
200
+ /** Compares `major.minor.patch` versions, ignoring a prerelease suffix: negative, zero or positive. */
201
+ export function compareVersions(a, b) {
202
+ const parts = (v) => v.split(/[-+]/)[0].split('.').map((n) => Number.parseInt(n, 10) || 0);
203
+ const [x, y] = [parts(a), parts(b)];
204
+ for (let i = 0; i < 3; i++) {
205
+ if ((x[i] ?? 0) !== (y[i] ?? 0))
206
+ return (x[i] ?? 0) - (y[i] ?? 0);
207
+ }
208
+ return 0;
209
+ }
210
+ function values(properties) {
211
+ return Object.keys(properties).length ? ['--values', JSON.stringify(properties)] : [];
212
+ }
213
+ function writeFlags(options) {
214
+ const flags = [];
215
+ if (options.publish)
216
+ flags.push('--publish');
217
+ if (options.dryRun)
218
+ flags.push('--dry-run');
219
+ if (options.lang)
220
+ flags.push('--lang', options.lang);
221
+ return flags;
222
+ }
@@ -0,0 +1,71 @@
1
+ import { OptiCli, type WriteOptions } from '../opticli.ts';
2
+ import type { ApplyResult, ContentRef, Plan, PlanIds, Properties, WriteResult } from '../types.ts';
3
+ import { type SeedState } from './setup.ts';
4
+ /** The `opticliSandbox` option: the page each worker makes for its tests' content. */
5
+ export interface SandboxOptions {
6
+ /** Where: a ref, or "$id" of a step in the seed plan. */
7
+ parent: ContentRef;
8
+ /** A page type its parent allows, that allows the test pages below it. */
9
+ type: string;
10
+ /** Values for it, such as `{ PageVisibleInMenu: false }` to keep it out of menus. */
11
+ properties?: Properties;
12
+ }
13
+ /** The options to set in playwright.config's `use`: `defineConfig<{}, OptiCliWorkerOptions>({ use: { opticliSandbox: ... } })`. */
14
+ export type OptiCliWorkerOptions = {
15
+ opticliSandbox?: SandboxOptions;
16
+ };
17
+ export interface CreatedPage extends WriteResult {
18
+ /** The page's URL (host-relative), for `page.goto`; for a draft, where it will be once published. */
19
+ url: string;
20
+ }
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`. */
30
+ export declare class Cms {
31
+ readonly opticli: OptiCli;
32
+ /** Where {@link page} creates content unless given a parent: the worker's sandbox. */
33
+ readonly parent: ContentRef | undefined;
34
+ private readonly created;
35
+ constructor(opticli: OptiCli,
36
+ /** Where {@link page} creates content unless given a parent: the worker's sandbox. */
37
+ parent: ContentRef | undefined);
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 & {
40
+ parent?: ContentRef;
41
+ }): Promise<CreatedPage>;
42
+ /** Saves a draft of the change unless `publish`. On seeded content, the next run's seed puts it back. */
43
+ set(ref: ContentRef, properties: Properties, options?: WriteOptions): Promise<WriteResult>;
44
+ /** Publishes the latest version, or the version `ref` names (`123_456`). */
45
+ publish(ref: ContentRef): Promise<WriteResult>;
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>;
51
+ /** Deletes what the test made, newest first. Content below other tracked content goes with it. */
52
+ cleanup(): Promise<void>;
53
+ private track;
54
+ /** Refuses `keep` below content that goes after the test or worker, before anything is saved. */
55
+ private checkKeep;
56
+ }
57
+ type TestFixtures = {
58
+ cms: Cms;
59
+ };
60
+ type WorkerFixtures<Id extends string> = {
61
+ opticli: OptiCli;
62
+ seed: SeedState<Id>;
63
+ sandbox: ContentRef | undefined;
64
+ opticliSandbox: SandboxOptions | undefined;
65
+ };
66
+ /**
67
+ * The fixtures, with `seed.refs` and `seed.urls` typed by the seed plan's step ids:
68
+ * `export const test = createTest<typeof seedPlan>();` (the plan written `as const satisfies Plan`).
69
+ */
70
+ export declare function createTest<P extends Plan = Plan>(): import("@playwright/test").TestType<import("@playwright/test").PlaywrightTestArgs & import("@playwright/test").PlaywrightTestOptions & TestFixtures, import("@playwright/test").PlaywrightWorkerArgs & import("@playwright/test").PlaywrightWorkerOptions & WorkerFixtures<PlanIds<P> extends never ? string : PlanIds<P>>>;
71
+ export {};
@@ -0,0 +1,133 @@
1
+ // Playwright fixtures:
2
+ // - `opticli` (worker): the CLI, run as setup ran it.
3
+ // - `seed` (worker): refs and URLs of the seed plan's content.
4
+ // - `sandbox` (worker): with the `opticliSandbox` option, a page per worker that test content goes below; it goes to the
5
+ // recycle bin when the worker ends, with whatever a failed test left behind.
6
+ // - `cms` (test): writes that are undone after the test.
7
+ // - `baseURL`: the site opticli serves.
8
+ import { test as base } from '@playwright/test';
9
+ import { OptiCli, OptiCliError } from "../opticli.js";
10
+ import { readSeedState } from "./setup.js";
11
+ /** Writes that a test's content gets, undone after the test unless `keep`. */
12
+ export class Cms {
13
+ opticli;
14
+ parent;
15
+ created = [];
16
+ constructor(opticli,
17
+ /** Where {@link page} creates content unless given a parent: the worker's sandbox. */
18
+ parent) {
19
+ this.opticli = opticli;
20
+ this.parent = parent;
21
+ }
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
+ if (!parent)
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]);
29
+ const result = await this.opticli.create(parent, type, name, properties, { ...options, publish: options.publish ?? true });
30
+ if (!keep)
31
+ this.track(result.ref, parent);
32
+ if (!result.url)
33
+ throw new Error(`${type} ${result.ref} has no URL: is ${type} a page type with a template?`);
34
+ return result;
35
+ }
36
+ /** Saves a draft of the change unless `publish`. On seeded content, the next run's seed puts it back. */
37
+ set(ref, properties, options = {}) {
38
+ return this.opticli.set(ref, properties, options);
39
+ }
40
+ /** Publishes the latest version, or the version `ref` names (`123_456`). */
41
+ publish(ref) {
42
+ return this.opticli.publish(ref);
43
+ }
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
+ }
53
+ const result = await this.opticli.apply(plan, options);
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);
60
+ }
61
+ }
62
+ return result;
63
+ }
64
+ /** Deletes what the test made, newest first. Content below other tracked content goes with it. */
65
+ async cleanup() {
66
+ const errors = [];
67
+ for (const ref of [...this.created].reverse()) {
68
+ try {
69
+ await this.opticli.delete(ref, { ignoreReferences: true });
70
+ }
71
+ catch (e) {
72
+ if (!(e instanceof OptiCliError && e.code === 'not_found'))
73
+ errors.push(e);
74
+ }
75
+ }
76
+ this.created.length = 0;
77
+ if (errors.length)
78
+ throw new AggregateError(errors, `opticli cleanup: ${errors.length} delete(s) failed`);
79
+ }
80
+ track(ref, parent) {
81
+ // Below tracked or planned ("$id") content: deleting the top item takes it along.
82
+ if (parent && (parent.startsWith('$') || this.created.includes(parent)))
83
+ return;
84
+ this.created.push(ref);
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';
100
+ }
101
+ /**
102
+ * The fixtures, with `seed.refs` and `seed.urls` typed by the seed plan's step ids:
103
+ * `export const test = createTest<typeof seedPlan>();` (the plan written `as const satisfies Plan`).
104
+ */
105
+ export function createTest() {
106
+ return base.extend({
107
+ opticliSandbox: [undefined, { option: true, scope: 'worker' }],
108
+ seed: [async ({}, use) => use(readSeedState()), { scope: 'worker' }],
109
+ opticli: [async ({ seed }, use) => use(new OptiCli({ ...seed.opticli, command: [...seed.opticli.command] })), { scope: 'worker' }],
110
+ sandbox: [
111
+ async ({ opticli, seed, opticliSandbox }, use, workerInfo) => {
112
+ if (!opticliSandbox)
113
+ return use(undefined);
114
+ const parent = opticliSandbox.parent.startsWith('$')
115
+ ? seed.refs[opticliSandbox.parent.slice(1)]
116
+ : opticliSandbox.parent;
117
+ if (!parent)
118
+ throw new Error(`opticliSandbox.parent ${opticliSandbox.parent} is no step id of the seed plan.`);
119
+ const name = `Playwright worker ${workerInfo.workerIndex} ${new Date().toISOString()}`;
120
+ const page = await opticli.create(parent, opticliSandbox.type, name, opticliSandbox.properties ?? {}, { publish: true });
121
+ await use(page.ref);
122
+ await opticli.delete(page.ref, { ignoreReferences: true });
123
+ },
124
+ { scope: 'worker' },
125
+ ],
126
+ baseURL: async ({ seed }, use) => use(seed.baseURL),
127
+ cms: async ({ opticli, sandbox }, use) => {
128
+ const cms = new Cms(opticli, sandbox);
129
+ await use(cms);
130
+ await cms.cleanup();
131
+ },
132
+ });
133
+ }
@@ -0,0 +1,15 @@
1
+ export { Cms, createTest } from './fixtures.ts';
2
+ export type { CreatedPage, KeepOption, OptiCliWorkerOptions, SandboxOptions } from './fixtures.ts';
3
+ export { defineSetup, readSeedState, stateVariable } from './setup.ts';
4
+ export type { SeedState, SetupOptions } from './setup.ts';
5
+ export { OptiCliError } from '../opticli.ts';
6
+ export { expect } from '@playwright/test';
7
+ /** The fixtures, with `seed.refs` keyed by any string; `createTest<typeof plan>()` types them by the plan's ids. */
8
+ export declare const test: import("@playwright/test").TestType<import("@playwright/test").PlaywrightTestArgs & import("@playwright/test").PlaywrightTestOptions & {
9
+ cms: import("./fixtures.ts").Cms;
10
+ }, import("@playwright/test").PlaywrightWorkerArgs & import("@playwright/test").PlaywrightWorkerOptions & {
11
+ opticli: import("../opticli.ts").OptiCli;
12
+ seed: import("./setup.ts").SeedState<string>;
13
+ sandbox: import("../types.ts").ContentRef | undefined;
14
+ opticliSandbox: import("./fixtures.ts").SandboxOptions | undefined;
15
+ }>;
@@ -0,0 +1,8 @@
1
+ // opticli/playwright: start the site, seed content and clean up in Playwright tests.
2
+ import { createTest } from "./fixtures.js";
3
+ export { Cms, createTest } from "./fixtures.js";
4
+ export { defineSetup, readSeedState, stateVariable } from "./setup.js";
5
+ export { OptiCliError } from "../opticli.js";
6
+ export { expect } from '@playwright/test';
7
+ /** The fixtures, with `seed.refs` keyed by any string; `createTest<typeof plan>()` types them by the plan's ids. */
8
+ export const test = createTest();
@@ -0,0 +1,50 @@
1
+ import { type OptiCliOptions } from '../opticli.ts';
2
+ import type { ContentRef, Plan } from '../types.ts';
3
+ export interface SetupOptions {
4
+ /**
5
+ * Content the tests read, applied with `--update-existing` on every run: the first run creates it, later ones only
6
+ * repair what changed. Give it a `guidNamespace`, and an `id` on every create, block and upload.
7
+ */
8
+ plan?: Plan;
9
+ /** Where the plan's files (`upload`, `"@file"` values) are; they must be inside it. Default: the working directory. */
10
+ planDir?: string;
11
+ /** Publish what the plan saves. Default: true. */
12
+ publish?: boolean;
13
+ /** Build the site before starting it (`serve --build`). Default: `OPTICLI_BUILD=1`. */
14
+ build?: boolean;
15
+ /** Leave a site that setup started running after the tests, or after setup failed. Default: `OPTICLI_KEEP_SITE=1`. */
16
+ keepSite?: boolean;
17
+ /** How to run opticli, and which project. */
18
+ opticli?: Pick<OptiCliOptions, 'command' | 'project' | 'cwd'>;
19
+ }
20
+ /** What setup hands the workers, in {@link stateVariable}. */
21
+ export interface SeedState<Id extends string = string> {
22
+ /** Where the browser reaches the site. */
23
+ baseURL: string;
24
+ /** How setup ran opticli, so the workers run the same. */
25
+ opticli: {
26
+ command: readonly string[];
27
+ project?: string;
28
+ cwd?: string;
29
+ };
30
+ /** The plan's step `id` → content ref. */
31
+ refs: Record<Id, ContentRef>;
32
+ /** The plan's step `id` → its URL, host-relative, for content that has one (pages, media). */
33
+ urls: Partial<Record<Id, string>>;
34
+ }
35
+ /** The environment variable that carries the {@link SeedState} from setup to the workers. */
36
+ export declare const stateVariable = "OPTICLI_PLAYWRIGHT_STATE";
37
+ /**
38
+ * Playwright's `globalSetup`:
39
+ *
40
+ * ```ts
41
+ * // global-setup.ts
42
+ * export default defineSetup({ plan: seedPlan, planDir: import.meta.dirname });
43
+ * ```
44
+ *
45
+ * It reuses a site that runs already (`opticli serve`, IDE mode) and leaves it running; a site it starts, it stops
46
+ * after the tests.
47
+ */
48
+ export declare function defineSetup(options?: SetupOptions): () => Promise<() => Promise<void>>;
49
+ /** The workers' copy of what setup did. */
50
+ export declare function readSeedState<Id extends string = string>(): SeedState<Id>;
@@ -0,0 +1,87 @@
1
+ // Playwright's globalSetup: start the site, check opticli's version, apply the seed plan, and hand what the workers need
2
+ // to them through the environment. It returns the teardown, which Playwright runs after the tests.
3
+ import { compareVersions, OptiCli } from "../opticli.js";
4
+ import { requiredOpticli } from "../version.js";
5
+ /** The environment variable that carries the {@link SeedState} from setup to the workers. */
6
+ export const stateVariable = 'OPTICLI_PLAYWRIGHT_STATE';
7
+ /**
8
+ * Playwright's `globalSetup`:
9
+ *
10
+ * ```ts
11
+ * // global-setup.ts
12
+ * export default defineSetup({ plan: seedPlan, planDir: import.meta.dirname });
13
+ * ```
14
+ *
15
+ * It reuses a site that runs already (`opticli serve`, IDE mode) and leaves it running; a site it starts, it stops
16
+ * after the tests.
17
+ */
18
+ export function defineSetup(options = {}) {
19
+ return async () => {
20
+ const opticli = new OptiCli({
21
+ ...options.opticli,
22
+ onWarnings: (warnings, args) => console.warn(`opticli ${args[0]}: ${warnings.join(' ')}`),
23
+ });
24
+ const before = await opticli.serveStatus();
25
+ const startedSite = before.state !== 'running';
26
+ const keepSite = options.keepSite ?? process.env.OPTICLI_KEEP_SITE === '1';
27
+ const teardown = async () => {
28
+ if (startedSite && !keepSite)
29
+ await opticli.serveStop();
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;
41
+ };
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
+ }
70
+ /** The workers' copy of what setup did. */
71
+ export function readSeedState() {
72
+ const json = process.env[stateVariable];
73
+ if (!json) {
74
+ throw new Error("No opticli setup ran: set globalSetup in playwright.config to a file that exports defineSetup() from '@epinova/opticli/playwright'.");
75
+ }
76
+ return JSON.parse(json);
77
+ }
78
+ function checkVersions(cli, agent) {
79
+ const upgrade = 'dotnet tool update -g OptiCli (or dotnet tool update OptiCli where a tool manifest pins it)';
80
+ if (compareVersions(cli, requiredOpticli) < 0) {
81
+ throw new Error(`This package needs opticli ${requiredOpticli} or newer; ${cli} runs. Upgrade it: ${upgrade}.`);
82
+ }
83
+ if (agent && compareVersions(agent, requiredOpticli) < 0) {
84
+ throw new Error(`The site runs the site agent of opticli ${agent}, older than ${requiredOpticli}. ` +
85
+ 'Restart it (opticli serve --stop, or restart it in the IDE) so it runs the agent of the installed opticli.');
86
+ }
87
+ }