@limetech/lime-scenario-harness 0.0.0-development
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 +120 -0
- package/dist/actions.d.ts +47 -0
- package/dist/actions.js +122 -0
- package/dist/api.d.ts +81 -0
- package/dist/api.js +221 -0
- package/dist/auth.d.ts +23 -0
- package/dist/auth.js +109 -0
- package/dist/config.d.ts +17 -0
- package/dist/config.js +34 -0
- package/dist/fixtures.d.ts +25 -0
- package/dist/fixtures.js +118 -0
- package/dist/index.d.ts +8 -0
- package/dist/index.js +26 -0
- package/dist/translations.d.ts +17 -0
- package/dist/translations.js +69 -0
- package/dist/versions.d.ts +23 -0
- package/dist/versions.js +98 -0
- package/package.json +35 -0
package/README.md
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
# lime-scenario-harness
|
|
2
|
+
|
|
3
|
+
The standard library for Lime CRM **scenario tests** — the Playwright suites
|
|
4
|
+
that verify a package's user-facing behavior against a running Lime CRM,
|
|
5
|
+
whether that is a CI boot or a deployed cloud environment.
|
|
6
|
+
|
|
7
|
+
Published to public npm as `@limetech/lime-scenario-harness`. Extracted from
|
|
8
|
+
the Packers team's e2e-verification proof of concept
|
|
9
|
+
(solution-packers-dev#409).
|
|
10
|
+
|
|
11
|
+
## Ownership rule
|
|
12
|
+
|
|
13
|
+
Platform-shaped surfaces belong here: login and session handling, an API
|
|
14
|
+
client for seeding and readback, tracked cleanup of test data, promoted-action
|
|
15
|
+
interactions, translation assertions from a package's own shipped `en.po`,
|
|
16
|
+
version-skew detection, the fault reporter, and `checkpoint()` evidence
|
|
17
|
+
screenshots.
|
|
18
|
+
|
|
19
|
+
Package knowledge — which action is "Close ticket", field names, dialogs —
|
|
20
|
+
stays in each package's own helpers next to its specs.
|
|
21
|
+
|
|
22
|
+
## What a spec imports
|
|
23
|
+
|
|
24
|
+
```ts
|
|
25
|
+
import {
|
|
26
|
+
test, // Playwright's test + fault reporter and `cleanup` fixtures
|
|
27
|
+
expect,
|
|
28
|
+
requireEnvironment, // unmet needs FAIL by default (PW_ALLOW_SKIPS=1 demotes to skips)
|
|
29
|
+
requireConfig,
|
|
30
|
+
checkpoint, // named screenshot attached to the report as evidence
|
|
31
|
+
createObject, // API client: seed, read back, update, delete
|
|
32
|
+
listObjects,
|
|
33
|
+
testDataName, // '[scenario-test] - <spec> <timestamp>' naming for seeded data
|
|
34
|
+
translation, // the English caption the package ships for a translation key
|
|
35
|
+
clickPromotedAction, // match actions by behavior, never caption
|
|
36
|
+
expectFrontendLoaded, // asserts the package's bundle loaded; warns on version skew
|
|
37
|
+
} from '@limetech/lime-scenario-harness';
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
## Environment contract
|
|
41
|
+
|
|
42
|
+
The target is described by one set of variables, shared with the composed
|
|
43
|
+
scenario-test workflow in lime-workflows:
|
|
44
|
+
|
|
45
|
+
| Variable | Meaning |
|
|
46
|
+
| --------------------------------------- | ------------------------------------------------------------------------------------------ |
|
|
47
|
+
| `LIME_BASE_URL` | App root of the target (the runner feeds it to Playwright's `baseURL`). Must end with `/`. |
|
|
48
|
+
| `LIME_TEST_USER` / `LIME_TEST_PASSWORD` | Dedicated test user. Never personal credentials outside a throwaway local run. |
|
|
49
|
+
| `LIME_API_PREFIX` | API path prefix under the app root (cloud: `<app-name>/`, local: empty). |
|
|
50
|
+
|
|
51
|
+
Runner-internal knobs, defaulting to the conventional layout under the
|
|
52
|
+
runner's working directory:
|
|
53
|
+
|
|
54
|
+
| Variable | Default | Meaning |
|
|
55
|
+
| -------------------------- | -------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
|
56
|
+
| `LIME_SCENARIO_STAGE_DIR` | `./.discovered` | Where discovery staged each package's files (one directory per Playwright project) and `versions.json`. |
|
|
57
|
+
| `LIME_SCENARIO_AUTH_STATE` | `./.auth/state.json` | Where `authenticate()` saves the login session for `storageState`. |
|
|
58
|
+
| `PW_ALLOW_SKIPS` | unset | `1` demotes unmet `requireEnvironment` needs to skips. Only for tiers that knowingly run under-provisioned targets (a PR-gate boot without package data). A verification run against a provisioned environment must NOT set it: a silent skip there reads as a pass. |
|
|
59
|
+
|
|
60
|
+
## Runner wiring
|
|
61
|
+
|
|
62
|
+
One setup project logs in once; every suite reuses the session:
|
|
63
|
+
|
|
64
|
+
```ts
|
|
65
|
+
// auth.setup.ts — uses Playwright's plain `test`: the harness fault
|
|
66
|
+
// reporter must not interfere with the login flow.
|
|
67
|
+
import { test as setup } from '@playwright/test';
|
|
68
|
+
import { authenticate } from '@limetech/lime-scenario-harness';
|
|
69
|
+
|
|
70
|
+
setup('authenticate', async ({ page }) => {
|
|
71
|
+
await authenticate(page);
|
|
72
|
+
});
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
```ts
|
|
76
|
+
// playwright.config.ts (the shape; see the composed workflow for the real one)
|
|
77
|
+
import { authStatePath } from '@limetech/lime-scenario-harness';
|
|
78
|
+
|
|
79
|
+
export default defineConfig({
|
|
80
|
+
use: { baseURL: process.env.LIME_BASE_URL },
|
|
81
|
+
projects: [
|
|
82
|
+
// No storageState here: Playwright loads that file when it creates
|
|
83
|
+
// a context, so the setup project must run without one — the file
|
|
84
|
+
// does not exist until authenticate() writes it.
|
|
85
|
+
{ name: 'setup', testMatch: /auth\.setup\.ts/ },
|
|
86
|
+
{
|
|
87
|
+
name: 'example-package',
|
|
88
|
+
testDir: './.discovered/example-package',
|
|
89
|
+
dependencies: ['setup'],
|
|
90
|
+
use: { storageState: authStatePath() },
|
|
91
|
+
},
|
|
92
|
+
],
|
|
93
|
+
});
|
|
94
|
+
```
|
|
95
|
+
|
|
96
|
+
Credentials are injected via the DOM (`setSilently`), not `fill()`:
|
|
97
|
+
Playwright's HTML report prints `fill()` arguments verbatim, and reports are
|
|
98
|
+
uploaded as CI artifacts that GitHub's log-only secret masking does not cover.
|
|
99
|
+
|
|
100
|
+
## Test data
|
|
101
|
+
|
|
102
|
+
Everything a spec seeds goes through the API, named with `testDataName()`
|
|
103
|
+
(the `[scenario-test]` prefix), tracked on the `cleanup` fixture and removed
|
|
104
|
+
after the test. Cleanup never fails a test but never passes silently either:
|
|
105
|
+
targets are shared and persistent, so anything left behind is reported.
|
|
106
|
+
Sweeps for data orphaned by crashed runs key on the exported
|
|
107
|
+
`TEST_DATA_PREFIX`.
|
|
108
|
+
|
|
109
|
+
## Developing
|
|
110
|
+
|
|
111
|
+
```sh
|
|
112
|
+
npm ci
|
|
113
|
+
npm run build # tsc → dist/
|
|
114
|
+
npm test # the harness's own unit tests (no browser involved)
|
|
115
|
+
npm run format
|
|
116
|
+
```
|
|
117
|
+
|
|
118
|
+
Releases are semantic-release on push to `main` (conventional commits;
|
|
119
|
+
`feat:`/`fix:` release). Publishing uses npm trusted publishing (OIDC) — no
|
|
120
|
+
npm token exists in this repo.
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
import { type APIRequestContext, type Locator, type Page } from '@playwright/test';
|
|
2
|
+
/**
|
|
3
|
+
* The promoted actions a limetype's card view configures - empty when the
|
|
4
|
+
* target configures none, throwing when the config cannot be read at all.
|
|
5
|
+
*/
|
|
6
|
+
export declare function getPromotedActions(request: APIRequestContext, limetype: string): Promise<unknown[]>;
|
|
7
|
+
/**
|
|
8
|
+
* The component a form action configures for one of its fields, when the
|
|
9
|
+
* solution overrides the default input (e.g. a hierarchy picker instead
|
|
10
|
+
* of a plain relation picker).
|
|
11
|
+
*/
|
|
12
|
+
export declare function formFieldComponent(action: unknown, field: string): string | undefined;
|
|
13
|
+
/** The literal string a standard-actions value config holds (constant or formula). */
|
|
14
|
+
export declare function valueLiteral(value: unknown): string | undefined;
|
|
15
|
+
/** The literal string an action writes to a field. A Jinja formula never equals a bare key. */
|
|
16
|
+
export declare function writtenValue(action: unknown, field: string): string | undefined;
|
|
17
|
+
/** The caption the action's button renders, en.po English when the label is a translation key. */
|
|
18
|
+
export declare function buttonName(action: unknown): string;
|
|
19
|
+
/**
|
|
20
|
+
* Clicks a promoted action, on the bar or behind its "+N" overflow menu.
|
|
21
|
+
*
|
|
22
|
+
* An overflowed item stays in the DOM at `opacity: 0`, which Playwright
|
|
23
|
+
* still counts as visible, so the path is decided by the item's reflected
|
|
24
|
+
* `is-visible` attribute instead. The overflow menu is portalled to the
|
|
25
|
+
* document body; the trigger's `aria-controls` names the portal container,
|
|
26
|
+
* scoping the menu-item lookup to the menu this bar opened.
|
|
27
|
+
*/
|
|
28
|
+
export declare function clickPromotedAction(page: Page, name: string): Promise<void>;
|
|
29
|
+
/**
|
|
30
|
+
* Asserts the card offers no action with this caption, on the bar or behind
|
|
31
|
+
* its overflow.
|
|
32
|
+
*
|
|
33
|
+
* Waiting for the bar to hold any item at all comes first. An action bar
|
|
34
|
+
* rebuilds itself after a command runs, and an absence read off a bar that
|
|
35
|
+
* has not rendered yet is an absence of everything.
|
|
36
|
+
*/
|
|
37
|
+
export declare function expectPromotedActionGone(page: Page, name: string): Promise<void>;
|
|
38
|
+
/** The dialog limepkg-standard-actions opens for an open-form action. */
|
|
39
|
+
export declare function formDialog(page: Page): Locator;
|
|
40
|
+
/**
|
|
41
|
+
* The dialog's confirm button - the one primary button in its footer.
|
|
42
|
+
* Located by shape rather than caption: the caption belongs to another
|
|
43
|
+
* package's catalog, which no spec can resolve.
|
|
44
|
+
*/
|
|
45
|
+
export declare function formDialogConfirm(page: Page): Locator;
|
|
46
|
+
/** The dialog's cancel button: the footer button captioned Cancel. */
|
|
47
|
+
export declare function formDialogCancel(page: Page): Locator;
|
package/dist/actions.js
ADDED
|
@@ -0,0 +1,122 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.getPromotedActions = getPromotedActions;
|
|
4
|
+
exports.formFieldComponent = formFieldComponent;
|
|
5
|
+
exports.valueLiteral = valueLiteral;
|
|
6
|
+
exports.writtenValue = writtenValue;
|
|
7
|
+
exports.buttonName = buttonName;
|
|
8
|
+
exports.clickPromotedAction = clickPromotedAction;
|
|
9
|
+
exports.expectPromotedActionGone = expectPromotedActionGone;
|
|
10
|
+
exports.formDialog = formDialog;
|
|
11
|
+
exports.formDialogConfirm = formDialogConfirm;
|
|
12
|
+
exports.formDialogCancel = formDialogCancel;
|
|
13
|
+
const test_1 = require("@playwright/test");
|
|
14
|
+
const api_1 = require("./api");
|
|
15
|
+
const translations_1 = require("./translations");
|
|
16
|
+
/** A translation key ('helpdesk-park.quick_action.1d') as opposed to a
|
|
17
|
+
* caption typed straight into the config ('Close ticket'). */
|
|
18
|
+
const TRANSLATION_KEY = /^\S+(\.\S+)+$/;
|
|
19
|
+
/**
|
|
20
|
+
* The promoted actions a limetype's card view configures - empty when the
|
|
21
|
+
* target configures none, throwing when the config cannot be read at all.
|
|
22
|
+
*/
|
|
23
|
+
async function getPromotedActions(request, limetype) {
|
|
24
|
+
const configEnvelope = await (0, api_1.findConfig)(request, `webclient_views.${limetype}`);
|
|
25
|
+
const actions = (0, api_1.pick)(configEnvelope, 'config', 'general', 'promotedActions');
|
|
26
|
+
return Array.isArray(actions) ? actions : [];
|
|
27
|
+
}
|
|
28
|
+
/**
|
|
29
|
+
* The component a form action configures for one of its fields, when the
|
|
30
|
+
* solution overrides the default input (e.g. a hierarchy picker instead
|
|
31
|
+
* of a plain relation picker).
|
|
32
|
+
*/
|
|
33
|
+
function formFieldComponent(action, field) {
|
|
34
|
+
const name = (0, api_1.pick)(action, 'params', 'properties', field, 'options', 'component', 'name');
|
|
35
|
+
return typeof name === 'string' && name !== '' ? name : undefined;
|
|
36
|
+
}
|
|
37
|
+
/** The literal string a standard-actions value config holds (constant or formula). */
|
|
38
|
+
function valueLiteral(value) {
|
|
39
|
+
const literal = (0, api_1.pick)(value, 'constant') ?? (0, api_1.pick)(value, 'formula');
|
|
40
|
+
return typeof literal === 'string' && literal !== '' ? literal : undefined;
|
|
41
|
+
}
|
|
42
|
+
/** The literal string an action writes to a field. A Jinja formula never equals a bare key. */
|
|
43
|
+
function writtenValue(action, field) {
|
|
44
|
+
return valueLiteral((0, api_1.pick)(action, 'params', 'limeObject', field));
|
|
45
|
+
}
|
|
46
|
+
/** The caption the action's button renders, en.po English when the label is a translation key. */
|
|
47
|
+
function buttonName(action) {
|
|
48
|
+
const label = (0, api_1.pick)(action, 'label');
|
|
49
|
+
if (typeof label !== 'string' || !label) {
|
|
50
|
+
throw new Error(`No caption derivable for action label ${JSON.stringify(label)}`);
|
|
51
|
+
}
|
|
52
|
+
return TRANSLATION_KEY.test(label) ? (0, translations_1.translation)(label) : label;
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* Clicks a promoted action, on the bar or behind its "+N" overflow menu.
|
|
56
|
+
*
|
|
57
|
+
* An overflowed item stays in the DOM at `opacity: 0`, which Playwright
|
|
58
|
+
* still counts as visible, so the path is decided by the item's reflected
|
|
59
|
+
* `is-visible` attribute instead. The overflow menu is portalled to the
|
|
60
|
+
* document body; the trigger's `aria-controls` names the portal container,
|
|
61
|
+
* scoping the menu-item lookup to the menu this bar opened.
|
|
62
|
+
*/
|
|
63
|
+
async function clickPromotedAction(page, name) {
|
|
64
|
+
const item = barItems(page)
|
|
65
|
+
.filter({ has: page.getByText(name, { exact: true }) })
|
|
66
|
+
.first();
|
|
67
|
+
await item.waitFor();
|
|
68
|
+
if ((await item.getAttribute('is-visible')) !== null) {
|
|
69
|
+
await item.getByRole('button', { name, exact: true }).click();
|
|
70
|
+
return;
|
|
71
|
+
}
|
|
72
|
+
const trigger = page.locator('limel-action-bar button[slot="trigger"]');
|
|
73
|
+
await trigger.click();
|
|
74
|
+
const menuId = await trigger.getAttribute('aria-controls');
|
|
75
|
+
if (!menuId) {
|
|
76
|
+
throw new Error('The overflow trigger names no menu (aria-controls)');
|
|
77
|
+
}
|
|
78
|
+
await page
|
|
79
|
+
.locator(`[id="${menuId}"]`)
|
|
80
|
+
.getByRole('menuitem', { name, exact: true })
|
|
81
|
+
.click();
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Every action the bar holds, whether it shows one on the bar itself or
|
|
85
|
+
* hides it behind the "+N" overflow: `limel-action-bar` renders an item per
|
|
86
|
+
* action either way, and only dims the overflowed ones.
|
|
87
|
+
*/
|
|
88
|
+
function barItems(page) {
|
|
89
|
+
return page.locator('limel-action-bar limel-action-bar-item');
|
|
90
|
+
}
|
|
91
|
+
/**
|
|
92
|
+
* Asserts the card offers no action with this caption, on the bar or behind
|
|
93
|
+
* its overflow.
|
|
94
|
+
*
|
|
95
|
+
* Waiting for the bar to hold any item at all comes first. An action bar
|
|
96
|
+
* rebuilds itself after a command runs, and an absence read off a bar that
|
|
97
|
+
* has not rendered yet is an absence of everything.
|
|
98
|
+
*/
|
|
99
|
+
async function expectPromotedActionGone(page, name) {
|
|
100
|
+
await (0, test_1.expect)(barItems(page).first()).toBeAttached();
|
|
101
|
+
await (0, test_1.expect)(barItems(page).filter({ has: page.getByText(name, { exact: true }) })).toHaveCount(0);
|
|
102
|
+
}
|
|
103
|
+
/** The dialog limepkg-standard-actions opens for an open-form action. */
|
|
104
|
+
function formDialog(page) {
|
|
105
|
+
return page.locator('lwc-limepkg-standard-actions-form-dialog');
|
|
106
|
+
}
|
|
107
|
+
/**
|
|
108
|
+
* The dialog's confirm button - the one primary button in its footer.
|
|
109
|
+
* Located by shape rather than caption: the caption belongs to another
|
|
110
|
+
* package's catalog, which no spec can resolve.
|
|
111
|
+
*/
|
|
112
|
+
function formDialogConfirm(page) {
|
|
113
|
+
return formDialog(page)
|
|
114
|
+
.locator('limel-button[slot="button"][primary]')
|
|
115
|
+
.getByRole('button');
|
|
116
|
+
}
|
|
117
|
+
/** The dialog's cancel button: the footer button captioned Cancel. */
|
|
118
|
+
function formDialogCancel(page) {
|
|
119
|
+
return formDialog(page)
|
|
120
|
+
.locator('limel-button[slot="button"]')
|
|
121
|
+
.getByRole('button', { name: 'Cancel', exact: true });
|
|
122
|
+
}
|
package/dist/api.d.ts
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
import { APIRequestContext, APIResponse } from '@playwright/test';
|
|
2
|
+
export interface LimeObject {
|
|
3
|
+
_id: number;
|
|
4
|
+
[key: string]: unknown;
|
|
5
|
+
}
|
|
6
|
+
export interface OptionValue {
|
|
7
|
+
key: string;
|
|
8
|
+
id: number;
|
|
9
|
+
text: string;
|
|
10
|
+
}
|
|
11
|
+
/**
|
|
12
|
+
* A path under the target's app root, carrying the deployment's API prefix
|
|
13
|
+
* (cloud environments serve the app from a named sub-path).
|
|
14
|
+
*/
|
|
15
|
+
export declare function targetUrl(path: string): string;
|
|
16
|
+
export declare function isRecord(value: unknown): value is Record<string, unknown>;
|
|
17
|
+
/**
|
|
18
|
+
* Marks every record the harness seeds. Sweeps that remove data orphaned by
|
|
19
|
+
* crashed runs key on this exact string, so it is part of the contract.
|
|
20
|
+
*/
|
|
21
|
+
export declare const TEST_DATA_PREFIX = "[scenario-test]";
|
|
22
|
+
/**
|
|
23
|
+
* The display name for a record a spec creates: prefixed so a human can
|
|
24
|
+
* tell test data from real data, timestamped so runs never collide.
|
|
25
|
+
*/
|
|
26
|
+
export declare function testDataName(specName: string): string;
|
|
27
|
+
/** The value at a path of keys inside nested plain objects, else undefined. */
|
|
28
|
+
export declare function pick(value: unknown, ...path: string[]): unknown;
|
|
29
|
+
/** An option field's API value, validated. */
|
|
30
|
+
export declare function optionValue(value: unknown): OptionValue;
|
|
31
|
+
/**
|
|
32
|
+
* The stable key of an option field's API value (e.g. 'waiting_us'). Named
|
|
33
|
+
* accessors rather than reads off `optionValue`, because which of the three
|
|
34
|
+
* fields a spec uses matters: `key` is the contract a package ships, `text`
|
|
35
|
+
* is what a user sees, and `id` is a row number that differs between
|
|
36
|
+
* databases.
|
|
37
|
+
*/
|
|
38
|
+
export declare function optionKey(value: unknown): string;
|
|
39
|
+
/** The display label of an option field's API value, in the session's locale. */
|
|
40
|
+
export declare function optionText(value: unknown): string;
|
|
41
|
+
/** Throws unless the response succeeded, quoting the body for diagnosis. */
|
|
42
|
+
export declare function ensureOk(response: APIResponse, action: string): Promise<void>;
|
|
43
|
+
export declare function createObject(request: APIRequestContext, limetype: string, data: Record<string, unknown>): Promise<LimeObject>;
|
|
44
|
+
export declare function getObject(request: APIRequestContext, limetype: string, id: number): Promise<LimeObject>;
|
|
45
|
+
/**
|
|
46
|
+
* The records in an api/v1 collection response, across the envelope shapes
|
|
47
|
+
* the API answers with (bare array/objects/_embedded). Records rather
|
|
48
|
+
* than limeobjects.
|
|
49
|
+
*/
|
|
50
|
+
export declare function collectionEntries(body: unknown): Record<string, unknown>[];
|
|
51
|
+
/** The limeobjects among a collection response's records. */
|
|
52
|
+
export declare function limeObjects(body: unknown): LimeObject[];
|
|
53
|
+
/** The limeobjects a collection query answers with. */
|
|
54
|
+
export declare function listObjects(request: APIRequestContext, limetype: string, params?: Record<string, string | number | boolean>): Promise<LimeObject[]>;
|
|
55
|
+
/** Every record a collection query answers with, including id-less rows. */
|
|
56
|
+
export declare function listEntries(request: APIRequestContext, limetype: string, params?: Record<string, string | number | boolean>): Promise<Record<string, unknown>[]>;
|
|
57
|
+
export declare function updateObject(request: APIRequestContext, limetype: string, id: number, data: Record<string, unknown>): Promise<LimeObject>;
|
|
58
|
+
/**
|
|
59
|
+
* Whether the limetype exists in the target, probed on the limetype
|
|
60
|
+
* metadata resource - false on 404. Other errors throw, so a broken
|
|
61
|
+
* environment cannot skip specs green.
|
|
62
|
+
*/
|
|
63
|
+
export declare function limetypeExists(request: APIRequestContext, limetype: string): Promise<boolean>;
|
|
64
|
+
/**
|
|
65
|
+
* A config section's envelope, or undefined when the target configures none
|
|
66
|
+
* (404). Any other failure throws, so a spec cannot mistake a broken or
|
|
67
|
+
* unauthorized config read for an unconfigured environment.
|
|
68
|
+
*/
|
|
69
|
+
export declare function findConfig(request: APIRequestContext, configKey: string): Promise<Record<string, unknown> | undefined>;
|
|
70
|
+
export declare function deleteObject(request: APIRequestContext, limetype: string, id: number): Promise<void>;
|
|
71
|
+
/** Tracks created test objects and best-effort deletes them after the test. */
|
|
72
|
+
export declare class Cleanup {
|
|
73
|
+
private request;
|
|
74
|
+
private items;
|
|
75
|
+
private disposers;
|
|
76
|
+
constructor(request: APIRequestContext);
|
|
77
|
+
track(limetype: string, id: number): void;
|
|
78
|
+
/** Registers a custom disposal (non-limeobject test data), run before tracked objects. */
|
|
79
|
+
defer(dispose: () => Promise<void>): void;
|
|
80
|
+
dispose(): Promise<void>;
|
|
81
|
+
}
|
package/dist/api.js
ADDED
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.Cleanup = exports.TEST_DATA_PREFIX = void 0;
|
|
4
|
+
exports.targetUrl = targetUrl;
|
|
5
|
+
exports.isRecord = isRecord;
|
|
6
|
+
exports.testDataName = testDataName;
|
|
7
|
+
exports.pick = pick;
|
|
8
|
+
exports.optionValue = optionValue;
|
|
9
|
+
exports.optionKey = optionKey;
|
|
10
|
+
exports.optionText = optionText;
|
|
11
|
+
exports.ensureOk = ensureOk;
|
|
12
|
+
exports.createObject = createObject;
|
|
13
|
+
exports.getObject = getObject;
|
|
14
|
+
exports.collectionEntries = collectionEntries;
|
|
15
|
+
exports.limeObjects = limeObjects;
|
|
16
|
+
exports.listObjects = listObjects;
|
|
17
|
+
exports.listEntries = listEntries;
|
|
18
|
+
exports.updateObject = updateObject;
|
|
19
|
+
exports.limetypeExists = limetypeExists;
|
|
20
|
+
exports.findConfig = findConfig;
|
|
21
|
+
exports.deleteObject = deleteObject;
|
|
22
|
+
const config_1 = require("./config");
|
|
23
|
+
const HTTP_NOT_FOUND = 404;
|
|
24
|
+
/**
|
|
25
|
+
* A path under the target's app root, carrying the deployment's API prefix
|
|
26
|
+
* (cloud environments serve the app from a named sub-path).
|
|
27
|
+
*/
|
|
28
|
+
function targetUrl(path) {
|
|
29
|
+
return `${(0, config_1.apiPrefix)()}${path}`;
|
|
30
|
+
}
|
|
31
|
+
function isRecord(value) {
|
|
32
|
+
return typeof value === 'object' && value !== null;
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* Marks every record the harness seeds. Sweeps that remove data orphaned by
|
|
36
|
+
* crashed runs key on this exact string, so it is part of the contract.
|
|
37
|
+
*/
|
|
38
|
+
exports.TEST_DATA_PREFIX = '[scenario-test]';
|
|
39
|
+
/**
|
|
40
|
+
* The display name for a record a spec creates: prefixed so a human can
|
|
41
|
+
* tell test data from real data, timestamped so runs never collide.
|
|
42
|
+
*/
|
|
43
|
+
function testDataName(specName) {
|
|
44
|
+
return `${exports.TEST_DATA_PREFIX} - ${specName} ${Date.now()}`;
|
|
45
|
+
}
|
|
46
|
+
/** The value at a path of keys inside nested plain objects, else undefined. */
|
|
47
|
+
function pick(value, ...path) {
|
|
48
|
+
let current = value;
|
|
49
|
+
for (const key of path) {
|
|
50
|
+
if (!isRecord(current)) {
|
|
51
|
+
return undefined;
|
|
52
|
+
}
|
|
53
|
+
current = current[key];
|
|
54
|
+
}
|
|
55
|
+
return current;
|
|
56
|
+
}
|
|
57
|
+
/** An option field's API value, validated. */
|
|
58
|
+
function optionValue(value) {
|
|
59
|
+
if (isRecord(value) &&
|
|
60
|
+
typeof value.id === 'number' &&
|
|
61
|
+
typeof value.key === 'string' &&
|
|
62
|
+
typeof value.text === 'string') {
|
|
63
|
+
return { id: value.id, key: value.key, text: value.text };
|
|
64
|
+
}
|
|
65
|
+
throw new Error(`Not an option value ({id, key, text}): ${JSON.stringify(value)}`);
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* The stable key of an option field's API value (e.g. 'waiting_us'). Named
|
|
69
|
+
* accessors rather than reads off `optionValue`, because which of the three
|
|
70
|
+
* fields a spec uses matters: `key` is the contract a package ships, `text`
|
|
71
|
+
* is what a user sees, and `id` is a row number that differs between
|
|
72
|
+
* databases.
|
|
73
|
+
*/
|
|
74
|
+
function optionKey(value) {
|
|
75
|
+
return optionValue(value).key;
|
|
76
|
+
}
|
|
77
|
+
/** The display label of an option field's API value, in the session's locale. */
|
|
78
|
+
function optionText(value) {
|
|
79
|
+
return optionValue(value).text;
|
|
80
|
+
}
|
|
81
|
+
/** Throws unless the response succeeded, quoting the body for diagnosis. */
|
|
82
|
+
async function ensureOk(response, action) {
|
|
83
|
+
if (!response.ok()) {
|
|
84
|
+
throw new Error(`Failed to ${action}: ${response.status()} ${(await response.text()).slice(0, 300)}`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
async function createObject(request, limetype, data) {
|
|
88
|
+
const response = await request.post(targetUrl(`api/v1/limeobject/${limetype}/`), { data });
|
|
89
|
+
await ensureOk(response, `create ${limetype}`);
|
|
90
|
+
return response.json();
|
|
91
|
+
}
|
|
92
|
+
async function getObject(request, limetype, id) {
|
|
93
|
+
const response = await request.get(targetUrl(`api/v1/limeobject/${limetype}/${id}/`));
|
|
94
|
+
await ensureOk(response, `get ${limetype}/${id}`);
|
|
95
|
+
return response.json();
|
|
96
|
+
}
|
|
97
|
+
/**
|
|
98
|
+
* The records in an api/v1 collection response, across the envelope shapes
|
|
99
|
+
* the API answers with (bare array/objects/_embedded). Records rather
|
|
100
|
+
* than limeobjects.
|
|
101
|
+
*/
|
|
102
|
+
function collectionEntries(body) {
|
|
103
|
+
const list = Array.isArray(body)
|
|
104
|
+
? body
|
|
105
|
+
: (pick(body, 'objects') ??
|
|
106
|
+
pick(body, '_embedded', 'limeobjects') ??
|
|
107
|
+
pick(body, '_embedded', 'limeobject'));
|
|
108
|
+
if (!Array.isArray(list)) {
|
|
109
|
+
throw new Error(`Unexpected collection response: ${JSON.stringify(body).slice(0, 200)}`);
|
|
110
|
+
}
|
|
111
|
+
return list.filter(isRecord);
|
|
112
|
+
}
|
|
113
|
+
/** The limeobjects among a collection response's records. */
|
|
114
|
+
function limeObjects(body) {
|
|
115
|
+
return collectionEntries(body).filter((entry) => typeof entry._id === 'number');
|
|
116
|
+
}
|
|
117
|
+
async function getCollection(request, limetype, params) {
|
|
118
|
+
const response = await request.get(targetUrl(`api/v1/limeobject/${limetype}/`), { params });
|
|
119
|
+
await ensureOk(response, `list ${limetype}`);
|
|
120
|
+
return response.json();
|
|
121
|
+
}
|
|
122
|
+
/** The limeobjects a collection query answers with. */
|
|
123
|
+
async function listObjects(request, limetype, params = {}) {
|
|
124
|
+
return limeObjects(await getCollection(request, limetype, params));
|
|
125
|
+
}
|
|
126
|
+
/** Every record a collection query answers with, including id-less rows. */
|
|
127
|
+
async function listEntries(request, limetype, params = {}) {
|
|
128
|
+
return collectionEntries(await getCollection(request, limetype, params));
|
|
129
|
+
}
|
|
130
|
+
async function updateObject(request, limetype, id, data) {
|
|
131
|
+
const response = await request.put(targetUrl(`api/v1/limeobject/${limetype}/${id}/`), { data });
|
|
132
|
+
await ensureOk(response, `update ${limetype}/${id}`);
|
|
133
|
+
return response.json();
|
|
134
|
+
}
|
|
135
|
+
/**
|
|
136
|
+
* Caches reads of what describes the target rather than its data (limetype
|
|
137
|
+
* metadata, admin config), otherwise re-fetched by every spec. Failures are
|
|
138
|
+
* never stored, and verification never writes config, so nothing goes stale.
|
|
139
|
+
*/
|
|
140
|
+
const targetFacts = new Map();
|
|
141
|
+
async function targetFact(key, read) {
|
|
142
|
+
if (!targetFacts.has(key)) {
|
|
143
|
+
targetFacts.set(key, await read());
|
|
144
|
+
}
|
|
145
|
+
return targetFacts.get(key);
|
|
146
|
+
}
|
|
147
|
+
/**
|
|
148
|
+
* Whether the limetype exists in the target, probed on the limetype
|
|
149
|
+
* metadata resource - false on 404. Other errors throw, so a broken
|
|
150
|
+
* environment cannot skip specs green.
|
|
151
|
+
*/
|
|
152
|
+
async function limetypeExists(request, limetype) {
|
|
153
|
+
return targetFact(`limetype:${limetype}`, async () => {
|
|
154
|
+
const response = await request.get(targetUrl(`api/v1/limetype/${limetype}/`));
|
|
155
|
+
if (response.status() === HTTP_NOT_FOUND) {
|
|
156
|
+
return false;
|
|
157
|
+
}
|
|
158
|
+
await ensureOk(response, `probe limetype ${limetype}`);
|
|
159
|
+
return true;
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
/**
|
|
163
|
+
* A config section's envelope, or undefined when the target configures none
|
|
164
|
+
* (404). Any other failure throws, so a spec cannot mistake a broken or
|
|
165
|
+
* unauthorized config read for an unconfigured environment.
|
|
166
|
+
*/
|
|
167
|
+
async function findConfig(request, configKey) {
|
|
168
|
+
return targetFact(`config:${configKey}`, async () => {
|
|
169
|
+
const response = await request.get(targetUrl(`lime-admin/v2/config/${configKey}/`));
|
|
170
|
+
if (response.status() === HTTP_NOT_FOUND) {
|
|
171
|
+
return undefined;
|
|
172
|
+
}
|
|
173
|
+
await ensureOk(response, `get config ${configKey}`);
|
|
174
|
+
return response.json();
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
async function deleteObject(request, limetype, id) {
|
|
178
|
+
const response = await request.delete(targetUrl(`api/v1/limeobject/${limetype}/${id}/`));
|
|
179
|
+
await ensureOk(response, `delete ${limetype}/${id}`);
|
|
180
|
+
}
|
|
181
|
+
/** Tracks created test objects and best-effort deletes them after the test. */
|
|
182
|
+
class Cleanup {
|
|
183
|
+
request;
|
|
184
|
+
items = [];
|
|
185
|
+
disposers = [];
|
|
186
|
+
constructor(request) {
|
|
187
|
+
this.request = request;
|
|
188
|
+
}
|
|
189
|
+
track(limetype, id) {
|
|
190
|
+
this.items.push({ limetype, id });
|
|
191
|
+
}
|
|
192
|
+
/** Registers a custom disposal (non-limeobject test data), run before tracked objects. */
|
|
193
|
+
defer(dispose) {
|
|
194
|
+
this.disposers.push(dispose);
|
|
195
|
+
}
|
|
196
|
+
async dispose() {
|
|
197
|
+
for (const dispose of this.disposers.reverse()) {
|
|
198
|
+
await bestEffort(dispose, 'a deferred disposal');
|
|
199
|
+
}
|
|
200
|
+
this.disposers = [];
|
|
201
|
+
for (const { limetype, id } of this.items.reverse()) {
|
|
202
|
+
await bestEffort(() => deleteObject(this.request, limetype, id), `${limetype}/${id}`);
|
|
203
|
+
}
|
|
204
|
+
this.items = [];
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
exports.Cleanup = Cleanup;
|
|
208
|
+
/**
|
|
209
|
+
* Cleanup never fails a test - a teardown error would mask the result the
|
|
210
|
+
* run is about. It must not pass unnoticed either: verification targets are
|
|
211
|
+
* shared and persistent, so anything left behind accumulates there.
|
|
212
|
+
*/
|
|
213
|
+
async function bestEffort(dispose, what) {
|
|
214
|
+
try {
|
|
215
|
+
await dispose();
|
|
216
|
+
}
|
|
217
|
+
catch (error) {
|
|
218
|
+
const detail = error instanceof Error ? error.message : String(error);
|
|
219
|
+
console.warn(`Cleanup left ${what} behind: ${detail}`);
|
|
220
|
+
}
|
|
221
|
+
}
|
package/dist/auth.d.ts
ADDED
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type Locator, type Page } from '@playwright/test';
|
|
2
|
+
/**
|
|
3
|
+
* Sets an input's value via the DOM instead of fill(): the HTML report
|
|
4
|
+
* prints fill() arguments verbatim, and it is uploaded as a CI artifact
|
|
5
|
+
* that GitHub's log-only secret masking does not cover.
|
|
6
|
+
*/
|
|
7
|
+
export declare const setSilently: (locator: Locator, value: string) => Promise<void>;
|
|
8
|
+
/**
|
|
9
|
+
* Logs the test user in through the form and saves the session to
|
|
10
|
+
* `authStatePath()` for every project to reuse via `storageState`.
|
|
11
|
+
*
|
|
12
|
+
* Call it from the runner's setup project with Playwright's *plain* `test`
|
|
13
|
+
* on purpose - the harness fault reporter must not interfere with the
|
|
14
|
+
* login flow:
|
|
15
|
+
*
|
|
16
|
+
* import { test as setup } from '@playwright/test';
|
|
17
|
+
* import { authenticate } from '@limetech/lime-scenario-harness';
|
|
18
|
+
*
|
|
19
|
+
* setup('authenticate', async ({ page }) => {
|
|
20
|
+
* await authenticate(page);
|
|
21
|
+
* });
|
|
22
|
+
*/
|
|
23
|
+
export declare function authenticate(page: Page): Promise<void>;
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.setSilently = void 0;
|
|
4
|
+
exports.authenticate = authenticate;
|
|
5
|
+
const api_1 = require("./api");
|
|
6
|
+
const config_1 = require("./config");
|
|
7
|
+
/** The userdata key the webclient checks to decide on the first-login pop-up. */
|
|
8
|
+
const PREFERENCES_KEY = 'webclient.user-preferences';
|
|
9
|
+
/**
|
|
10
|
+
* Sets an input's value via the DOM instead of fill(): the HTML report
|
|
11
|
+
* prints fill() arguments verbatim, and it is uploaded as a CI artifact
|
|
12
|
+
* that GitHub's log-only secret masking does not cover.
|
|
13
|
+
*/
|
|
14
|
+
const setSilently = (locator, value) => locator.evaluate((el, v) => {
|
|
15
|
+
el.value = v;
|
|
16
|
+
el.dispatchEvent(new Event('input', { bubbles: true, composed: true }));
|
|
17
|
+
el.dispatchEvent(new Event('change', { bubbles: true }));
|
|
18
|
+
}, value);
|
|
19
|
+
exports.setSilently = setSilently;
|
|
20
|
+
async function getJson(page, path) {
|
|
21
|
+
const response = await page.request.get((0, api_1.targetUrl)(path));
|
|
22
|
+
if (!response.ok()) {
|
|
23
|
+
throw new Error(`Failed to GET ${path}: ${response.status()}`);
|
|
24
|
+
}
|
|
25
|
+
return response.json();
|
|
26
|
+
}
|
|
27
|
+
/** The session's userdata entries, keyed as the webclient reads them. */
|
|
28
|
+
async function userdata(page) {
|
|
29
|
+
const entries = await getJson(page, 'webclient/userdata/');
|
|
30
|
+
const byKey = new Map();
|
|
31
|
+
for (const entry of Array.isArray(entries) ? entries : []) {
|
|
32
|
+
const key = (0, api_1.pick)(entry, 'key');
|
|
33
|
+
if (typeof key === 'string') {
|
|
34
|
+
byKey.set(key, (0, api_1.pick)(entry, 'data'));
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
return byKey;
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* The suite asserts English captions (each package's en.po, platform
|
|
41
|
+
* buttons), so the session's effective webclient language must be English:
|
|
42
|
+
* the user's saved preference when it is among the active languages,
|
|
43
|
+
* otherwise the server default. Fails rather than skips - every project
|
|
44
|
+
* depends on this setup.
|
|
45
|
+
*/
|
|
46
|
+
async function requireEnglishSession(page, entries) {
|
|
47
|
+
const envelope = await getJson(page, 'lime-admin/v2/config/webclient/');
|
|
48
|
+
const fallback = (0, api_1.pick)(envelope, 'config', 'languages', 'language');
|
|
49
|
+
const active = (0, api_1.pick)(envelope, 'config', 'languages', 'languages');
|
|
50
|
+
const preferred = (0, api_1.pick)(entries.get(PREFERENCES_KEY), 'language');
|
|
51
|
+
const effective = typeof preferred === 'string' &&
|
|
52
|
+
preferred !== '' &&
|
|
53
|
+
Array.isArray(active) &&
|
|
54
|
+
active.includes(preferred)
|
|
55
|
+
? preferred
|
|
56
|
+
: fallback;
|
|
57
|
+
if (typeof effective !== 'string' || !effective.startsWith('en')) {
|
|
58
|
+
throw new Error(`The webclient language for this session is ${JSON.stringify(effective)} - ` +
|
|
59
|
+
'the specs assert English captions, so set the test ' +
|
|
60
|
+
"user's language preference (or the solution default) to English");
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
/** The webclient opens the wizard when the user has no saved preferences.
|
|
64
|
+
* Its write-back on close is debounced; setup needs to await it else the write is lost.
|
|
65
|
+
*/
|
|
66
|
+
async function dismissFirstLoginWizard(page, entries) {
|
|
67
|
+
if (entries.get(PREFERENCES_KEY)) {
|
|
68
|
+
return;
|
|
69
|
+
}
|
|
70
|
+
const saved = page.waitForResponse((response) => response.url().includes(`webclient/userdata/${PREFERENCES_KEY}/`) &&
|
|
71
|
+
response.request().method() === 'POST');
|
|
72
|
+
await page.getByRole('button', { name: 'Save & close' }).click();
|
|
73
|
+
await saved;
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Logs the test user in through the form and saves the session to
|
|
77
|
+
* `authStatePath()` for every project to reuse via `storageState`.
|
|
78
|
+
*
|
|
79
|
+
* Call it from the runner's setup project with Playwright's *plain* `test`
|
|
80
|
+
* on purpose - the harness fault reporter must not interfere with the
|
|
81
|
+
* login flow:
|
|
82
|
+
*
|
|
83
|
+
* import { test as setup } from '@playwright/test';
|
|
84
|
+
* import { authenticate } from '@limetech/lime-scenario-harness';
|
|
85
|
+
*
|
|
86
|
+
* setup('authenticate', async ({ page }) => {
|
|
87
|
+
* await authenticate(page);
|
|
88
|
+
* });
|
|
89
|
+
*/
|
|
90
|
+
async function authenticate(page) {
|
|
91
|
+
const user = process.env.LIME_TEST_USER;
|
|
92
|
+
const password = process.env.LIME_TEST_PASSWORD;
|
|
93
|
+
if (!user || !password) {
|
|
94
|
+
throw new Error('LIME_TEST_USER and LIME_TEST_PASSWORD env vars are required (see README)');
|
|
95
|
+
}
|
|
96
|
+
await page.goto('client/login/');
|
|
97
|
+
await (0, exports.setSilently)(page.getByRole('textbox', { name: 'Username' }), user);
|
|
98
|
+
await (0, exports.setSilently)(page.getByRole('textbox', { name: 'Password' }), password);
|
|
99
|
+
await page.getByRole('button', { name: /sign in|log in/i }).click();
|
|
100
|
+
await page
|
|
101
|
+
.waitForURL((url) => !url.pathname.includes('/login'))
|
|
102
|
+
.catch(() => {
|
|
103
|
+
throw new Error('Login did not leave /login - check LIME_TEST_USER/LIME_TEST_PASSWORD');
|
|
104
|
+
});
|
|
105
|
+
const entries = await userdata(page);
|
|
106
|
+
await requireEnglishSession(page, entries);
|
|
107
|
+
await dismissFirstLoginWizard(page, entries);
|
|
108
|
+
await page.context().storageState({ path: (0, config_1.authStatePath)() });
|
|
109
|
+
}
|
package/dist/config.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Resolved per call rather than at module load: the harness lives in
|
|
3
|
+
* node_modules, so nothing about the runner's layout can be baked in at
|
|
4
|
+
* import time, and a runner may set the variables after importing.
|
|
5
|
+
*/
|
|
6
|
+
/** API path prefix under the target's app root (cloud deployments serve the app from a named sub-path). */
|
|
7
|
+
export declare function apiPrefix(): string;
|
|
8
|
+
/**
|
|
9
|
+
* Where the runner staged each discovered package's files, one directory
|
|
10
|
+
* per Playwright project name, plus versions.json.
|
|
11
|
+
*/
|
|
12
|
+
export declare function stageDir(): string;
|
|
13
|
+
/**
|
|
14
|
+
* Saved login session (cookie), written by `authenticate` and read by every
|
|
15
|
+
* project via `storageState`. Keep it out of version control.
|
|
16
|
+
*/
|
|
17
|
+
export declare function authStatePath(): string;
|
package/dist/config.js
ADDED
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.apiPrefix = apiPrefix;
|
|
7
|
+
exports.stageDir = stageDir;
|
|
8
|
+
exports.authStatePath = authStatePath;
|
|
9
|
+
const path_1 = __importDefault(require("path"));
|
|
10
|
+
/**
|
|
11
|
+
* Resolved per call rather than at module load: the harness lives in
|
|
12
|
+
* node_modules, so nothing about the runner's layout can be baked in at
|
|
13
|
+
* import time, and a runner may set the variables after importing.
|
|
14
|
+
*/
|
|
15
|
+
/** API path prefix under the target's app root (cloud deployments serve the app from a named sub-path). */
|
|
16
|
+
function apiPrefix() {
|
|
17
|
+
return process.env.LIME_API_PREFIX ?? '';
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Where the runner staged each discovered package's files, one directory
|
|
21
|
+
* per Playwright project name, plus versions.json.
|
|
22
|
+
*/
|
|
23
|
+
function stageDir() {
|
|
24
|
+
return (process.env.LIME_SCENARIO_STAGE_DIR ??
|
|
25
|
+
path_1.default.resolve(process.cwd(), '.discovered'));
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Saved login session (cookie), written by `authenticate` and read by every
|
|
29
|
+
* project via `storageState`. Keep it out of version control.
|
|
30
|
+
*/
|
|
31
|
+
function authStatePath() {
|
|
32
|
+
return (process.env.LIME_SCENARIO_AUTH_STATE ??
|
|
33
|
+
path_1.default.resolve(process.cwd(), '.auth', 'state.json'));
|
|
34
|
+
}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
import { type APIRequestContext, type BrowserContext, type Page } from '@playwright/test';
|
|
2
|
+
import { Cleanup } from './api';
|
|
3
|
+
/** How long a readback waits for a write the action under test triggered */
|
|
4
|
+
export declare const WRITE_SETTLE_MS = 15000;
|
|
5
|
+
interface Fixtures {
|
|
6
|
+
reportErrors: void;
|
|
7
|
+
cleanup: Cleanup;
|
|
8
|
+
}
|
|
9
|
+
interface WorkerFixtures {
|
|
10
|
+
sharedContext: BrowserContext;
|
|
11
|
+
}
|
|
12
|
+
/** Playwright's `test` with the fault reporter and `cleanup` fixtures (see README). */
|
|
13
|
+
export declare const test: import("@playwright/test").TestType<import("@playwright/test").PlaywrightTestArgs & import("@playwright/test").PlaywrightTestOptions & Fixtures, import("@playwright/test").PlaywrightWorkerArgs & import("@playwright/test").PlaywrightWorkerOptions & WorkerFixtures>;
|
|
14
|
+
/**
|
|
15
|
+
* Declares what the spec needs from the target environment: the limetype
|
|
16
|
+
* it drives, the config it asserts against, the wiring that produces the
|
|
17
|
+
* effect. Unmet requirements fail by default - a silent skip would read
|
|
18
|
+
* as a pass; `PW_ALLOW_SKIPS=1` demotes them to skips (see README).
|
|
19
|
+
*/
|
|
20
|
+
export declare function requireEnvironment(provided: boolean, reason: string): asserts provided;
|
|
21
|
+
/** Reads a config section and requires the target to have it, in one step. */
|
|
22
|
+
export declare function requireConfig(request: APIRequestContext, configKey: string): Promise<Record<string, unknown>>;
|
|
23
|
+
/** Attaches a named screenshot to the test report as verification evidence. */
|
|
24
|
+
export declare function checkpoint(page: Page, name: string): Promise<void>;
|
|
25
|
+
export {};
|
package/dist/fixtures.js
ADDED
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.test = exports.WRITE_SETTLE_MS = void 0;
|
|
4
|
+
exports.requireEnvironment = requireEnvironment;
|
|
5
|
+
exports.requireConfig = requireConfig;
|
|
6
|
+
exports.checkpoint = checkpoint;
|
|
7
|
+
const test_1 = require("@playwright/test");
|
|
8
|
+
const api_1 = require("./api");
|
|
9
|
+
/**
|
|
10
|
+
* Read per call, not at module load: a consumer's playwright.config may
|
|
11
|
+
* import the harness before it runs dotenv, and this module loads with the
|
|
12
|
+
* barrel import.
|
|
13
|
+
*/
|
|
14
|
+
const allowSkips = () => process.env.PW_ALLOW_SKIPS === '1';
|
|
15
|
+
const HTTP_CLIENT_ERROR = 400;
|
|
16
|
+
/** How long a readback waits for a write the action under test triggered */
|
|
17
|
+
exports.WRITE_SETTLE_MS = 15_000;
|
|
18
|
+
/** Any host the Intercom widget loads itself and its content from. */
|
|
19
|
+
const INTERCOM_HOST = /(^|\.)(intercom\.io|intercomcdn\.com|intercomassets\.com)/;
|
|
20
|
+
/**
|
|
21
|
+
* Answers every Intercom request with no content, so the widget never boots.
|
|
22
|
+
* Left to load, it drops survey banners/discoverability over the UI at times.
|
|
23
|
+
* The banner's iframe swallows pointer events and times out
|
|
24
|
+
* whichever spec happens to be running.
|
|
25
|
+
*/
|
|
26
|
+
async function blockIntercom(context) {
|
|
27
|
+
await context.route((url) => INTERCOM_HOST.test(url.hostname), (route) => route.fulfill({ status: 204, body: '' }));
|
|
28
|
+
}
|
|
29
|
+
/** Playwright's `test` with the fault reporter and `cleanup` fixtures (see README). */
|
|
30
|
+
exports.test = test_1.test.extend({
|
|
31
|
+
/**
|
|
32
|
+
* One browser context per worker rather than one per test: a fresh
|
|
33
|
+
* context starts with an empty HTTP cache and re-downloads the
|
|
34
|
+
* webclient and every package bundle against a cloud target.
|
|
35
|
+
* Pages stay per test; cookies and localStorage outlive a test (see README).
|
|
36
|
+
* `use` mixes context options with runner-level settings `newContext`
|
|
37
|
+
* rejects; those are stripped and the rest forwarded.
|
|
38
|
+
*/
|
|
39
|
+
sharedContext: [
|
|
40
|
+
async ({ browser }, use, workerInfo) => {
|
|
41
|
+
const { trace, screenshot, launchOptions, defaultBrowserType, ...contextOptions } = workerInfo.project.use;
|
|
42
|
+
const context = await browser.newContext(contextOptions);
|
|
43
|
+
await blockIntercom(context);
|
|
44
|
+
await use(context);
|
|
45
|
+
await context.close();
|
|
46
|
+
},
|
|
47
|
+
{ scope: 'worker' },
|
|
48
|
+
],
|
|
49
|
+
page: async ({ sharedContext }, use) => {
|
|
50
|
+
const page = await sharedContext.newPage();
|
|
51
|
+
await use(page);
|
|
52
|
+
await page.close();
|
|
53
|
+
},
|
|
54
|
+
reportErrors: [
|
|
55
|
+
async ({ page }, use) => {
|
|
56
|
+
const faults = [];
|
|
57
|
+
const note = (message) => {
|
|
58
|
+
faults.push(message);
|
|
59
|
+
console.warn(message);
|
|
60
|
+
};
|
|
61
|
+
page.on('response', (response) => {
|
|
62
|
+
if (response.status() >= HTTP_CLIENT_ERROR) {
|
|
63
|
+
note(`${response.url()} responded with ${response.status()}`);
|
|
64
|
+
}
|
|
65
|
+
});
|
|
66
|
+
page.on('pageerror', (error) => {
|
|
67
|
+
note(`page error: ${error.message}`);
|
|
68
|
+
});
|
|
69
|
+
page.on('console', (message) => {
|
|
70
|
+
if (message.type() === 'error') {
|
|
71
|
+
note(`console error: ${message.text()} (${message.location().url})`);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
await use();
|
|
75
|
+
if (faults.length > 0) {
|
|
76
|
+
await exports.test.info().attach(`${faults.length} browser fault(s)`, {
|
|
77
|
+
body: faults.join('\n'),
|
|
78
|
+
contentType: 'text/plain',
|
|
79
|
+
});
|
|
80
|
+
}
|
|
81
|
+
},
|
|
82
|
+
{ auto: true },
|
|
83
|
+
],
|
|
84
|
+
cleanup: async ({ request }, use) => {
|
|
85
|
+
const cleanup = new api_1.Cleanup(request);
|
|
86
|
+
await use(cleanup);
|
|
87
|
+
await cleanup.dispose();
|
|
88
|
+
},
|
|
89
|
+
});
|
|
90
|
+
/**
|
|
91
|
+
* Declares what the spec needs from the target environment: the limetype
|
|
92
|
+
* it drives, the config it asserts against, the wiring that produces the
|
|
93
|
+
* effect. Unmet requirements fail by default - a silent skip would read
|
|
94
|
+
* as a pass; `PW_ALLOW_SKIPS=1` demotes them to skips (see README).
|
|
95
|
+
*/
|
|
96
|
+
function requireEnvironment(provided, reason) {
|
|
97
|
+
if (provided) {
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (allowSkips()) {
|
|
101
|
+
exports.test.skip(true, reason);
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
throw new Error(`Environment not ready for this spec: ${reason}`);
|
|
105
|
+
}
|
|
106
|
+
/** Reads a config section and requires the target to have it, in one step. */
|
|
107
|
+
async function requireConfig(request, configKey) {
|
|
108
|
+
const config = await (0, api_1.findConfig)(request, configKey);
|
|
109
|
+
requireEnvironment(config !== undefined, `the ${configKey} config is not set up in this environment`);
|
|
110
|
+
return config;
|
|
111
|
+
}
|
|
112
|
+
/** Attaches a named screenshot to the test report as verification evidence. */
|
|
113
|
+
async function checkpoint(page, name) {
|
|
114
|
+
await exports.test.info().attach(name, {
|
|
115
|
+
body: await page.screenshot(),
|
|
116
|
+
contentType: 'image/png',
|
|
117
|
+
});
|
|
118
|
+
}
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __createBinding = (this && this.__createBinding) || (Object.create ? (function(o, m, k, k2) {
|
|
3
|
+
if (k2 === undefined) k2 = k;
|
|
4
|
+
var desc = Object.getOwnPropertyDescriptor(m, k);
|
|
5
|
+
if (!desc || ("get" in desc ? !m.__esModule : desc.writable || desc.configurable)) {
|
|
6
|
+
desc = { enumerable: true, get: function() { return m[k]; } };
|
|
7
|
+
}
|
|
8
|
+
Object.defineProperty(o, k2, desc);
|
|
9
|
+
}) : (function(o, m, k, k2) {
|
|
10
|
+
if (k2 === undefined) k2 = k;
|
|
11
|
+
o[k2] = m[k];
|
|
12
|
+
}));
|
|
13
|
+
var __exportStar = (this && this.__exportStar) || function(m, exports) {
|
|
14
|
+
for (var p in m) if (p !== "default" && !Object.prototype.hasOwnProperty.call(exports, p)) __createBinding(exports, m, p);
|
|
15
|
+
};
|
|
16
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
17
|
+
exports.expect = void 0;
|
|
18
|
+
var test_1 = require("@playwright/test");
|
|
19
|
+
Object.defineProperty(exports, "expect", { enumerable: true, get: function () { return test_1.expect; } });
|
|
20
|
+
__exportStar(require("./actions"), exports);
|
|
21
|
+
__exportStar(require("./api"), exports);
|
|
22
|
+
__exportStar(require("./auth"), exports);
|
|
23
|
+
__exportStar(require("./config"), exports);
|
|
24
|
+
__exportStar(require("./fixtures"), exports);
|
|
25
|
+
__exportStar(require("./translations"), exports);
|
|
26
|
+
__exportStar(require("./versions"), exports);
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* msgid-msgstr pairs in a po catalog. Only single-line entries are parsed,
|
|
3
|
+
* which covers every message a package ships today; the multi-line form
|
|
4
|
+
* appears only in the catalog header, which carries no caption.
|
|
5
|
+
*/
|
|
6
|
+
export declare function parsePoCaptions(catalog: string): Map<string, string>;
|
|
7
|
+
/**
|
|
8
|
+
* The caption a catalog holds for a translation key. Catalogs hold msgids
|
|
9
|
+
* unprefixed while a config carries them prefixed with the package's
|
|
10
|
+
* library name, so a key that misses is looked up again without its
|
|
11
|
+
* leading segment - which needs no assumption about how that name relates
|
|
12
|
+
* to the package's distribution name. Throws on unknown keys so a caption
|
|
13
|
+
* lookup can never silently target the wrong element.
|
|
14
|
+
*/
|
|
15
|
+
export declare function resolveCaption(captions: Map<string, string>, key: string): string;
|
|
16
|
+
/** The English caption the running spec's package ships for a translation key. */
|
|
17
|
+
export declare function translation(key: string): string;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.parsePoCaptions = parsePoCaptions;
|
|
7
|
+
exports.resolveCaption = resolveCaption;
|
|
8
|
+
exports.translation = translation;
|
|
9
|
+
const fs_1 = __importDefault(require("fs"));
|
|
10
|
+
const path_1 = __importDefault(require("path"));
|
|
11
|
+
const config_1 = require("./config");
|
|
12
|
+
const fixtures_1 = require("./fixtures");
|
|
13
|
+
const captionsByDir = new Map();
|
|
14
|
+
/**
|
|
15
|
+
* The running spec's staged package directory (where the runner's discovery
|
|
16
|
+
* put its files). Taken from the project rather than the spec's own path,
|
|
17
|
+
* which would resolve to a subfolder for a package that groups its specs.
|
|
18
|
+
*/
|
|
19
|
+
function packageDir() {
|
|
20
|
+
return path_1.default.resolve((0, config_1.stageDir)(), fixtures_1.test.info().project.name);
|
|
21
|
+
}
|
|
22
|
+
/** Resolves the po escape sequences for quote and backslash. */
|
|
23
|
+
const unescapePo = (value) => value.replace(/\\(["\\])/g, '$1');
|
|
24
|
+
/**
|
|
25
|
+
* One msgid "..." line directly followed by its msgstr "..." line, with
|
|
26
|
+
* either line ending: a catalog read from a Windows checkout is CRLF, and
|
|
27
|
+
* `.` never matches the `\r`.
|
|
28
|
+
*/
|
|
29
|
+
const SINGLE_LINE_ENTRY = /^msgid "(.+)"\r?\nmsgstr "(.+)"\r?$/gm;
|
|
30
|
+
/**
|
|
31
|
+
* msgid-msgstr pairs in a po catalog. Only single-line entries are parsed,
|
|
32
|
+
* which covers every message a package ships today; the multi-line form
|
|
33
|
+
* appears only in the catalog header, which carries no caption.
|
|
34
|
+
*/
|
|
35
|
+
function parsePoCaptions(catalog) {
|
|
36
|
+
const captions = new Map();
|
|
37
|
+
for (const [, msgid, msgstr] of catalog.matchAll(SINGLE_LINE_ENTRY)) {
|
|
38
|
+
captions.set(unescapePo(msgid), unescapePo(msgstr));
|
|
39
|
+
}
|
|
40
|
+
return captions;
|
|
41
|
+
}
|
|
42
|
+
function enPoCaptions(dir) {
|
|
43
|
+
let captions = captionsByDir.get(dir);
|
|
44
|
+
if (!captions) {
|
|
45
|
+
captions = parsePoCaptions(fs_1.default.readFileSync(path_1.default.join(dir, 'translations', 'en.po'), 'utf-8'));
|
|
46
|
+
captionsByDir.set(dir, captions);
|
|
47
|
+
}
|
|
48
|
+
return captions;
|
|
49
|
+
}
|
|
50
|
+
/**
|
|
51
|
+
* The caption a catalog holds for a translation key. Catalogs hold msgids
|
|
52
|
+
* unprefixed while a config carries them prefixed with the package's
|
|
53
|
+
* library name, so a key that misses is looked up again without its
|
|
54
|
+
* leading segment - which needs no assumption about how that name relates
|
|
55
|
+
* to the package's distribution name. Throws on unknown keys so a caption
|
|
56
|
+
* lookup can never silently target the wrong element.
|
|
57
|
+
*/
|
|
58
|
+
function resolveCaption(captions, key) {
|
|
59
|
+
const unprefixed = key.slice(key.indexOf('.') + 1);
|
|
60
|
+
const caption = captions.get(key) ?? captions.get(unprefixed);
|
|
61
|
+
if (!caption) {
|
|
62
|
+
throw new Error(`No en.po entry for ${key} (single-line entries only)`);
|
|
63
|
+
}
|
|
64
|
+
return caption;
|
|
65
|
+
}
|
|
66
|
+
/** The English caption the running spec's package ships for a translation key. */
|
|
67
|
+
function translation(key) {
|
|
68
|
+
return resolveCaption(enPoCaptions(packageDir()), key);
|
|
69
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { type Page } from '@playwright/test';
|
|
2
|
+
/** The package frontend version the webclient loaded, if it loaded one. */
|
|
3
|
+
export declare function loadedFrontendVersion(page: Page, packageName: string): Promise<string | undefined>;
|
|
4
|
+
/**
|
|
5
|
+
* Whether two versions name the same release across the dialects in play:
|
|
6
|
+
* a wheel carries the PEP 440 form (`1.4.0b2`), a release tag the semver
|
|
7
|
+
* one (`v1.4.0-beta.2`).
|
|
8
|
+
*/
|
|
9
|
+
export declare function sameVersion(one: string, other: string): boolean;
|
|
10
|
+
/**
|
|
11
|
+
* A warning message when the environment runs another version than the
|
|
12
|
+
* specs were staged from - undefined when they match, either version is
|
|
13
|
+
* unknown, or the specs were staged from a repo checkout.
|
|
14
|
+
*/
|
|
15
|
+
export declare function versionSkew(packageName: string, loadedVersion: string | undefined): string | undefined;
|
|
16
|
+
/**
|
|
17
|
+
* Asserts the webclient loaded the package's own frontend bundle, and
|
|
18
|
+
* records which version it was. Skew against the version the specs were
|
|
19
|
+
* staged from is a warning, not a failure: nothing guarantees the
|
|
20
|
+
* checkout's lock and the deployed environment agree, and saying so is
|
|
21
|
+
* more useful than refusing to verify.
|
|
22
|
+
*/
|
|
23
|
+
export declare function expectFrontendLoaded(page: Page, packageName: string): Promise<void>;
|
package/dist/versions.js
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
var __importDefault = (this && this.__importDefault) || function (mod) {
|
|
3
|
+
return (mod && mod.__esModule) ? mod : { "default": mod };
|
|
4
|
+
};
|
|
5
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
6
|
+
exports.loadedFrontendVersion = loadedFrontendVersion;
|
|
7
|
+
exports.sameVersion = sameVersion;
|
|
8
|
+
exports.versionSkew = versionSkew;
|
|
9
|
+
exports.expectFrontendLoaded = expectFrontendLoaded;
|
|
10
|
+
const test_1 = require("@playwright/test");
|
|
11
|
+
const fs_1 = __importDefault(require("fs"));
|
|
12
|
+
const path_1 = __importDefault(require("path"));
|
|
13
|
+
const config_1 = require("./config");
|
|
14
|
+
const fixtures_1 = require("./fixtures");
|
|
15
|
+
const versionsManifest = () => path_1.default.resolve((0, config_1.stageDir)(), 'versions.json');
|
|
16
|
+
/** The package frontend version the webclient loaded, if it loaded one. */
|
|
17
|
+
async function loadedFrontendVersion(page, packageName) {
|
|
18
|
+
const libName = packageName.replace(/-/g, '_');
|
|
19
|
+
return page.evaluate(({ packageName, libName }) => {
|
|
20
|
+
// …/<package-name>/<version>/dist/… (cloud CDN bundle)
|
|
21
|
+
const cdnBundle = new RegExp(`${packageName}/([^/]+)/dist/`);
|
|
22
|
+
// …/<lib_name>/lwc-package-<version>/… (locally served bundle)
|
|
23
|
+
const localBundle = new RegExp(`${libName}/lwc-[a-z]+-([^/]+)/`);
|
|
24
|
+
for (const entry of performance.getEntriesByType('resource')) {
|
|
25
|
+
const version = entry.name.match(cdnBundle)?.[1] ??
|
|
26
|
+
entry.name.match(localBundle)?.[1];
|
|
27
|
+
if (version) {
|
|
28
|
+
return version;
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return undefined;
|
|
32
|
+
}, { packageName, libName });
|
|
33
|
+
}
|
|
34
|
+
function stagedVersion(packageName) {
|
|
35
|
+
const manifest = versionsManifest();
|
|
36
|
+
if (!fs_1.default.existsSync(manifest)) {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
const staged = JSON.parse(fs_1.default.readFileSync(manifest, 'utf-8'));
|
|
40
|
+
return staged[packageName];
|
|
41
|
+
}
|
|
42
|
+
/** Normalizes the PEP 440 (1.4.0b1) and release-tag (1.4.0-beta.1) dialects to one form. */
|
|
43
|
+
const normalize = (version) => version
|
|
44
|
+
.toLowerCase()
|
|
45
|
+
.replace('-alpha.', 'a')
|
|
46
|
+
.replace('-beta.', 'b')
|
|
47
|
+
.replace('-rc.', 'rc')
|
|
48
|
+
.replace('-dev.', '.dev');
|
|
49
|
+
/**
|
|
50
|
+
* Whether two versions name the same release across the dialects in play:
|
|
51
|
+
* a wheel carries the PEP 440 form (`1.4.0b2`), a release tag the semver
|
|
52
|
+
* one (`v1.4.0-beta.2`).
|
|
53
|
+
*/
|
|
54
|
+
function sameVersion(one, other) {
|
|
55
|
+
return normalize(one) === normalize(other);
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* A warning message when the environment runs another version than the
|
|
59
|
+
* specs were staged from - undefined when they match, either version is
|
|
60
|
+
* unknown, or the specs were staged from a repo checkout.
|
|
61
|
+
*/
|
|
62
|
+
function versionSkew(packageName, loadedVersion) {
|
|
63
|
+
const staged = stagedVersion(packageName);
|
|
64
|
+
if (!staged ||
|
|
65
|
+
!loadedVersion ||
|
|
66
|
+
staged === 'local' ||
|
|
67
|
+
sameVersion(staged, loadedVersion)) {
|
|
68
|
+
return undefined;
|
|
69
|
+
}
|
|
70
|
+
return `Version skew: specs staged from ${packageName} ${staged}, environment runs ${loadedVersion}`;
|
|
71
|
+
}
|
|
72
|
+
/**
|
|
73
|
+
* Asserts the webclient loaded the package's own frontend bundle, and
|
|
74
|
+
* records which version it was. Skew against the version the specs were
|
|
75
|
+
* staged from is a warning, not a failure: nothing guarantees the
|
|
76
|
+
* checkout's lock and the deployed environment agree, and saying so is
|
|
77
|
+
* more useful than refusing to verify.
|
|
78
|
+
*/
|
|
79
|
+
async function expectFrontendLoaded(page, packageName) {
|
|
80
|
+
await test_1.expect
|
|
81
|
+
.poll(() => loadedFrontendVersion(page, packageName), {
|
|
82
|
+
message: `The webclient never loaded the ${packageName} frontend - is the package installed?`,
|
|
83
|
+
})
|
|
84
|
+
.toBeDefined();
|
|
85
|
+
const loaded = await loadedFrontendVersion(page, packageName);
|
|
86
|
+
fixtures_1.test.info().annotations.push({
|
|
87
|
+
type: 'loaded-version',
|
|
88
|
+
description: `${packageName} ${loaded}`,
|
|
89
|
+
});
|
|
90
|
+
const skew = versionSkew(packageName, loaded);
|
|
91
|
+
if (skew) {
|
|
92
|
+
console.warn(skew);
|
|
93
|
+
fixtures_1.test.info().annotations.push({
|
|
94
|
+
type: 'version-skew',
|
|
95
|
+
description: skew,
|
|
96
|
+
});
|
|
97
|
+
}
|
|
98
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@limetech/lime-scenario-harness",
|
|
3
|
+
"version": "0.0.0-development",
|
|
4
|
+
"description": "Standard library for Lime CRM scenario tests: login, API seeding, tracked cleanup, translation assertions, evidence screenshots.",
|
|
5
|
+
"license": "Apache-2.0",
|
|
6
|
+
"repository": {
|
|
7
|
+
"type": "git",
|
|
8
|
+
"url": "git+https://github.com/Lundalogik/lime-scenario-harness.git"
|
|
9
|
+
},
|
|
10
|
+
"main": "dist/index.js",
|
|
11
|
+
"types": "dist/index.d.ts",
|
|
12
|
+
"files": [
|
|
13
|
+
"dist"
|
|
14
|
+
],
|
|
15
|
+
"publishConfig": {
|
|
16
|
+
"access": "public"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "tsc -p tsconfig.build.json",
|
|
20
|
+
"prepack": "npm run build",
|
|
21
|
+
"test": "playwright test",
|
|
22
|
+
"typecheck": "tsc --noEmit",
|
|
23
|
+
"format": "prettier --write .",
|
|
24
|
+
"format:check": "prettier --check ."
|
|
25
|
+
},
|
|
26
|
+
"peerDependencies": {
|
|
27
|
+
"@playwright/test": ">=1.48.0 <2"
|
|
28
|
+
},
|
|
29
|
+
"devDependencies": {
|
|
30
|
+
"@playwright/test": "^1.58.0",
|
|
31
|
+
"@types/node": "^24.0.0",
|
|
32
|
+
"prettier": "^3.9.6",
|
|
33
|
+
"typescript": "^5.9.0"
|
|
34
|
+
}
|
|
35
|
+
}
|