@epinova/opticli 0.0.0-stage → 0.27.0-preview
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/LICENSE +373 -0
- package/README.md +168 -2
- package/lib/index.d.ts +4 -0
- package/lib/index.js +3 -0
- package/lib/opticli.d.ts +97 -0
- package/lib/opticli.js +208 -0
- package/lib/playwright/fixtures.d.ts +58 -0
- package/lib/playwright/fixtures.js +106 -0
- package/lib/playwright/index.d.ts +15 -0
- package/lib/playwright/index.js +8 -0
- package/lib/playwright/setup.d.ts +50 -0
- package/lib/playwright/setup.js +75 -0
- package/lib/types.d.ts +272 -0
- package/lib/types.js +4 -0
- package/lib/version.d.ts +4 -0
- package/lib/version.js +4 -0
- package/package.json +46 -4
package/lib/opticli.js
ADDED
|
@@ -0,0 +1,208 @@
|
|
|
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
|
+
this.project = options.project ?? (process.env.OPTICLI_PROJECT || undefined);
|
|
36
|
+
this.onWarnings = options.onWarnings;
|
|
37
|
+
}
|
|
38
|
+
/** Runs any opticli command with `--json` and returns its envelope; throws OptiCliError when `ok` is false. */
|
|
39
|
+
async run(args, options = {}) {
|
|
40
|
+
const full = [...args, '--json', ...(this.project ? ['--project', this.project] : [])];
|
|
41
|
+
const { stdout, stderr, exitCode } = await this.exec(full, options);
|
|
42
|
+
let envelope;
|
|
43
|
+
try {
|
|
44
|
+
envelope = JSON.parse(stdout);
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
const output = (stderr || stdout).trim().slice(0, 2000);
|
|
48
|
+
throw new OptiCliError({ code: 'internal', message: `no JSON on stdout (exit ${exitCode}): ${output}` }, exitCode, args);
|
|
49
|
+
}
|
|
50
|
+
if (!envelope.ok)
|
|
51
|
+
throw new OptiCliError(envelope.error, exitCode, args, envelope.meta?.version);
|
|
52
|
+
if (envelope.meta?.warnings?.length)
|
|
53
|
+
this.onWarnings?.(envelope.meta.warnings, args);
|
|
54
|
+
return envelope;
|
|
55
|
+
}
|
|
56
|
+
async data(args, options) {
|
|
57
|
+
return (await this.run(args, options)).data;
|
|
58
|
+
}
|
|
59
|
+
// serve
|
|
60
|
+
/** Starts the site with the site agent in the background (`serve`), or returns the one that runs. */
|
|
61
|
+
serve(options = {}) {
|
|
62
|
+
const timeout = options.timeoutSeconds ?? 180;
|
|
63
|
+
const args = ['serve', '--timeout', String(timeout)];
|
|
64
|
+
if (options.build)
|
|
65
|
+
args.push('--build');
|
|
66
|
+
if (options.port)
|
|
67
|
+
args.push('--port', String(options.port));
|
|
68
|
+
if (options.https)
|
|
69
|
+
args.push('--https');
|
|
70
|
+
// A build comes first, and may take minutes.
|
|
71
|
+
return this.run(args, { timeoutMs: (timeout + 600) * 1000 });
|
|
72
|
+
}
|
|
73
|
+
serveStatus() {
|
|
74
|
+
return this.data(['serve', '--status']);
|
|
75
|
+
}
|
|
76
|
+
serveStop() {
|
|
77
|
+
return this.data(['serve', '--stop']);
|
|
78
|
+
}
|
|
79
|
+
// reads
|
|
80
|
+
get(ref, options = {}) {
|
|
81
|
+
const args = ['get', ref];
|
|
82
|
+
if (options.lang)
|
|
83
|
+
args.push('--lang', options.lang);
|
|
84
|
+
if (options.version)
|
|
85
|
+
args.push('--version', options.version);
|
|
86
|
+
if (options.full)
|
|
87
|
+
args.push('--full');
|
|
88
|
+
return this.data(args);
|
|
89
|
+
}
|
|
90
|
+
/** Every language branch's path and absolute URL. Write results have `url` already. */
|
|
91
|
+
url(ref) {
|
|
92
|
+
return this.data(['url', ref]);
|
|
93
|
+
}
|
|
94
|
+
// writes
|
|
95
|
+
create(parent, type, name, properties = {}, options = {}) {
|
|
96
|
+
return this.data(['create', parent, '--type', type, '--name', name, ...values(properties), ...writeFlags(options)]);
|
|
97
|
+
}
|
|
98
|
+
/** Saves a draft of the change, unless `publish`. */
|
|
99
|
+
set(ref, properties, options = {}) {
|
|
100
|
+
const args = ['set', ref, ...values(properties), ...writeFlags(options)];
|
|
101
|
+
if (options.name)
|
|
102
|
+
args.push('--name', options.name);
|
|
103
|
+
if (options.from)
|
|
104
|
+
args.push('--from', options.from);
|
|
105
|
+
return this.data(args);
|
|
106
|
+
}
|
|
107
|
+
/** Publishes the latest version, or the version `ref` names (`123_456`). */
|
|
108
|
+
publish(ref, options = {}) {
|
|
109
|
+
const args = ['publish', ref];
|
|
110
|
+
if (options.includeDraft)
|
|
111
|
+
args.push('--include-draft');
|
|
112
|
+
if (options.dryRun)
|
|
113
|
+
args.push('--dry-run');
|
|
114
|
+
return this.data(args);
|
|
115
|
+
}
|
|
116
|
+
/** Moves content, with everything below it, to the recycle bin. */
|
|
117
|
+
delete(ref, options = {}) {
|
|
118
|
+
const args = ['delete', ref];
|
|
119
|
+
if (options.ignoreReferences)
|
|
120
|
+
args.push('--ignore-references');
|
|
121
|
+
if (options.dryRun)
|
|
122
|
+
args.push('--dry-run');
|
|
123
|
+
return this.data(args);
|
|
124
|
+
}
|
|
125
|
+
/**
|
|
126
|
+
* Runs a plan, sent on stdin. Its files (`upload`, `"@file"` values) are relative to `baseDir` and must be inside it.
|
|
127
|
+
* With the plan's `guidNamespace` and `updateExisting`, running it again only changes what differs.
|
|
128
|
+
*/
|
|
129
|
+
apply(plan, options = {}) {
|
|
130
|
+
const args = ['apply', '-'];
|
|
131
|
+
if (options.updateExisting)
|
|
132
|
+
args.push('--update-existing');
|
|
133
|
+
if (options.publish)
|
|
134
|
+
args.push('--publish');
|
|
135
|
+
if (options.dryRun)
|
|
136
|
+
args.push('--dry-run');
|
|
137
|
+
return this.data(args, { input: JSON.stringify(plan), cwd: options.baseDir, timeoutMs: 600_000 });
|
|
138
|
+
}
|
|
139
|
+
exec(args, options) {
|
|
140
|
+
const [program, ...prefix] = this.command;
|
|
141
|
+
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
|
+
});
|
|
147
|
+
let stdout = '';
|
|
148
|
+
let stderr = '';
|
|
149
|
+
child.stdout.setEncoding('utf8').on('data', (chunk) => (stdout += chunk));
|
|
150
|
+
child.stderr.setEncoding('utf8').on('data', (chunk) => (stderr += chunk));
|
|
151
|
+
child.on('error', (error) => reject(error.code === 'ENOENT'
|
|
152
|
+
? 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 }));
|
|
155
|
+
child.stdin.on('error', () => { }); // a CLI that exits before reading stdin
|
|
156
|
+
child.stdin.end(options.input ?? '');
|
|
157
|
+
});
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
/** The command that runs opticli from `cwd`: see {@link OptiCliOptions.command}. */
|
|
161
|
+
export function resolveCommand(cwd, env = process.env) {
|
|
162
|
+
const bin = env.OPTICLI;
|
|
163
|
+
if (bin)
|
|
164
|
+
return bin.endsWith('.dll') ? ['dotnet', bin] : [bin];
|
|
165
|
+
return findToolManifest(cwd) ? ['dotnet', 'tool', 'run', 'opticli'] : ['opticli'];
|
|
166
|
+
}
|
|
167
|
+
/** The nearest local tool manifest above `cwd` that has opticli, as `dotnet tool run` finds it. */
|
|
168
|
+
export function findToolManifest(cwd) {
|
|
169
|
+
for (let dir = resolve(cwd);; dir = dirname(dir)) {
|
|
170
|
+
for (const file of [join(dir, '.config', 'dotnet-tools.json'), join(dir, 'dotnet-tools.json')]) {
|
|
171
|
+
if (!existsSync(file))
|
|
172
|
+
continue;
|
|
173
|
+
try {
|
|
174
|
+
const tools = JSON.parse(readFileSync(file, 'utf8')).tools ?? {};
|
|
175
|
+
if (Object.values(tools).some((tool) => tool.commands?.includes('opticli')))
|
|
176
|
+
return file;
|
|
177
|
+
}
|
|
178
|
+
catch {
|
|
179
|
+
// Not JSON: dotnet would fail on it too. Keep looking above.
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (dirname(dir) === dir)
|
|
183
|
+
return undefined;
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
/** Compares `major.minor.patch` versions, ignoring a prerelease suffix: negative, zero or positive. */
|
|
187
|
+
export function compareVersions(a, b) {
|
|
188
|
+
const parts = (v) => v.split(/[-+]/)[0].split('.').map((n) => Number.parseInt(n, 10) || 0);
|
|
189
|
+
const [x, y] = [parts(a), parts(b)];
|
|
190
|
+
for (let i = 0; i < 3; i++) {
|
|
191
|
+
if ((x[i] ?? 0) !== (y[i] ?? 0))
|
|
192
|
+
return (x[i] ?? 0) - (y[i] ?? 0);
|
|
193
|
+
}
|
|
194
|
+
return 0;
|
|
195
|
+
}
|
|
196
|
+
function values(properties) {
|
|
197
|
+
return Object.keys(properties).length ? ['--values', JSON.stringify(properties)] : [];
|
|
198
|
+
}
|
|
199
|
+
function writeFlags(options) {
|
|
200
|
+
const flags = [];
|
|
201
|
+
if (options.publish)
|
|
202
|
+
flags.push('--publish');
|
|
203
|
+
if (options.dryRun)
|
|
204
|
+
flags.push('--dry-run');
|
|
205
|
+
if (options.lang)
|
|
206
|
+
flags.push('--lang', options.lang);
|
|
207
|
+
return flags;
|
|
208
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
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
|
+
/** Writes that a test's content gets, undone after the test. */
|
|
22
|
+
export declare class Cms {
|
|
23
|
+
readonly opticli: OptiCli;
|
|
24
|
+
/** Where {@link page} creates content unless given a parent: the worker's sandbox. */
|
|
25
|
+
readonly parent: ContentRef | undefined;
|
|
26
|
+
private readonly created;
|
|
27
|
+
constructor(opticli: OptiCli,
|
|
28
|
+
/** Where {@link page} creates content unless given a parent: the worker's sandbox. */
|
|
29
|
+
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 & {
|
|
32
|
+
parent?: ContentRef;
|
|
33
|
+
}): Promise<CreatedPage>;
|
|
34
|
+
/** Saves a draft of the change unless `publish`. On seeded content, the next run's seed puts it back. */
|
|
35
|
+
set(ref: ContentRef, properties: Properties, options?: WriteOptions): Promise<WriteResult>;
|
|
36
|
+
/** Publishes the latest version, or the version `ref` names (`123_456`). */
|
|
37
|
+
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>;
|
|
40
|
+
/** Deletes what the test made, newest first. Content below other tracked content goes with it. */
|
|
41
|
+
cleanup(): Promise<void>;
|
|
42
|
+
private track;
|
|
43
|
+
}
|
|
44
|
+
type TestFixtures = {
|
|
45
|
+
cms: Cms;
|
|
46
|
+
};
|
|
47
|
+
type WorkerFixtures<Id extends string> = {
|
|
48
|
+
opticli: OptiCli;
|
|
49
|
+
seed: SeedState<Id>;
|
|
50
|
+
sandbox: ContentRef | undefined;
|
|
51
|
+
opticliSandbox: SandboxOptions | undefined;
|
|
52
|
+
};
|
|
53
|
+
/**
|
|
54
|
+
* The fixtures, with `seed.refs` and `seed.urls` typed by the seed plan's step ids:
|
|
55
|
+
* `export const test = createTest<typeof seedPlan>();` (the plan written `as const satisfies Plan`).
|
|
56
|
+
*/
|
|
57
|
+
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>>>;
|
|
58
|
+
export {};
|
|
@@ -0,0 +1,106 @@
|
|
|
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. */
|
|
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. */
|
|
23
|
+
async page(type, name, properties = {}, options = {}) {
|
|
24
|
+
const parent = options.parent ?? 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
|
+
const result = await this.opticli.create(parent, type, name, properties, { ...options, publish: options.publish ?? true });
|
|
28
|
+
this.track(result.ref, parent);
|
|
29
|
+
if (!result.url)
|
|
30
|
+
throw new Error(`${type} ${result.ref} has no URL: is ${type} a page type with a template?`);
|
|
31
|
+
return result;
|
|
32
|
+
}
|
|
33
|
+
/** Saves a draft of the change unless `publish`. On seeded content, the next run's seed puts it back. */
|
|
34
|
+
set(ref, properties, options = {}) {
|
|
35
|
+
return this.opticli.set(ref, properties, options);
|
|
36
|
+
}
|
|
37
|
+
/** Publishes the latest version, or the version `ref` names (`123_456`). */
|
|
38
|
+
publish(ref) {
|
|
39
|
+
return this.opticli.publish(ref);
|
|
40
|
+
}
|
|
41
|
+
/** Runs a plan; what it creates below existing content is removed after the test. */
|
|
42
|
+
async apply(plan, options = {}) {
|
|
43
|
+
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);
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
return result;
|
|
50
|
+
}
|
|
51
|
+
/** Deletes what the test made, newest first. Content below other tracked content goes with it. */
|
|
52
|
+
async cleanup() {
|
|
53
|
+
const errors = [];
|
|
54
|
+
for (const ref of [...this.created].reverse()) {
|
|
55
|
+
try {
|
|
56
|
+
await this.opticli.delete(ref, { ignoreReferences: true });
|
|
57
|
+
}
|
|
58
|
+
catch (e) {
|
|
59
|
+
if (!(e instanceof OptiCliError && e.code === 'not_found'))
|
|
60
|
+
errors.push(e);
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
this.created.length = 0;
|
|
64
|
+
if (errors.length)
|
|
65
|
+
throw new AggregateError(errors, `opticli cleanup: ${errors.length} delete(s) failed`);
|
|
66
|
+
}
|
|
67
|
+
track(ref, parent) {
|
|
68
|
+
// Below tracked or planned ("$id") content: deleting the top item takes it along.
|
|
69
|
+
if (parent && (parent.startsWith('$') || this.created.includes(parent)))
|
|
70
|
+
return;
|
|
71
|
+
this.created.push(ref);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
/**
|
|
75
|
+
* The fixtures, with `seed.refs` and `seed.urls` typed by the seed plan's step ids:
|
|
76
|
+
* `export const test = createTest<typeof seedPlan>();` (the plan written `as const satisfies Plan`).
|
|
77
|
+
*/
|
|
78
|
+
export function createTest() {
|
|
79
|
+
return base.extend({
|
|
80
|
+
opticliSandbox: [undefined, { option: true, scope: 'worker' }],
|
|
81
|
+
seed: [async ({}, use) => use(readSeedState()), { scope: 'worker' }],
|
|
82
|
+
opticli: [async ({ seed }, use) => use(new OptiCli({ ...seed.opticli, command: [...seed.opticli.command] })), { scope: 'worker' }],
|
|
83
|
+
sandbox: [
|
|
84
|
+
async ({ opticli, seed, opticliSandbox }, use, workerInfo) => {
|
|
85
|
+
if (!opticliSandbox)
|
|
86
|
+
return use(undefined);
|
|
87
|
+
const parent = opticliSandbox.parent.startsWith('$')
|
|
88
|
+
? seed.refs[opticliSandbox.parent.slice(1)]
|
|
89
|
+
: opticliSandbox.parent;
|
|
90
|
+
if (!parent)
|
|
91
|
+
throw new Error(`opticliSandbox.parent ${opticliSandbox.parent} is no step id of the seed plan.`);
|
|
92
|
+
const name = `Playwright worker ${workerInfo.workerIndex} ${new Date().toISOString()}`;
|
|
93
|
+
const page = await opticli.create(parent, opticliSandbox.type, name, opticliSandbox.properties ?? {}, { publish: true });
|
|
94
|
+
await use(page.ref);
|
|
95
|
+
await opticli.delete(page.ref, { ignoreReferences: true });
|
|
96
|
+
},
|
|
97
|
+
{ scope: 'worker' },
|
|
98
|
+
],
|
|
99
|
+
baseURL: async ({ seed }, use) => use(seed.baseURL),
|
|
100
|
+
cms: async ({ opticli, sandbox }, use) => {
|
|
101
|
+
const cms = new Cms(opticli, sandbox);
|
|
102
|
+
await use(cms);
|
|
103
|
+
await cms.cleanup();
|
|
104
|
+
},
|
|
105
|
+
});
|
|
106
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export { Cms, createTest } from './fixtures.ts';
|
|
2
|
+
export type { CreatedPage, 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. 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,75 @@
|
|
|
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 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
|
+
const startedSite = before.state !== 'running';
|
|
52
|
+
return async () => {
|
|
53
|
+
if (startedSite && !(options.keepSite ?? process.env.OPTICLI_KEEP_SITE === '1'))
|
|
54
|
+
await opticli.serveStop();
|
|
55
|
+
};
|
|
56
|
+
};
|
|
57
|
+
}
|
|
58
|
+
/** The workers' copy of what setup did. */
|
|
59
|
+
export function readSeedState() {
|
|
60
|
+
const json = process.env[stateVariable];
|
|
61
|
+
if (!json) {
|
|
62
|
+
throw new Error("No opticli setup ran: set globalSetup in playwright.config to a file that exports defineSetup() from '@epinova/opticli/playwright'.");
|
|
63
|
+
}
|
|
64
|
+
return JSON.parse(json);
|
|
65
|
+
}
|
|
66
|
+
function checkVersions(cli, agent) {
|
|
67
|
+
const upgrade = 'dotnet tool update -g OptiCli (or dotnet tool update OptiCli where a tool manifest pins it)';
|
|
68
|
+
if (compareVersions(cli, requiredOpticli) < 0) {
|
|
69
|
+
throw new Error(`This package needs opticli ${requiredOpticli} or newer; ${cli} runs. Upgrade it: ${upgrade}.`);
|
|
70
|
+
}
|
|
71
|
+
if (agent && compareVersions(agent, requiredOpticli) < 0) {
|
|
72
|
+
throw new Error(`The site runs the site agent of opticli ${agent}, older than ${requiredOpticli}. ` +
|
|
73
|
+
'Restart it (opticli serve --stop, or restart it in the IDE) so it runs the agent of the installed opticli.');
|
|
74
|
+
}
|
|
75
|
+
}
|