@12-apps/discounts 1.11.0 → 1.12.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/dist/e2e/globs.d.ts +40 -0
- package/dist/e2e/globs.d.ts.map +1 -0
- package/dist/e2e/globs.js +66 -0
- package/dist/e2e/globs.js.map +1 -0
- package/dist/e2e/index.d.ts +12 -0
- package/dist/e2e/index.d.ts.map +1 -0
- package/dist/e2e/index.js +12 -0
- package/dist/e2e/index.js.map +1 -0
- package/dist/e2e/steps/journey.steps.d.ts +2 -0
- package/dist/e2e/steps/journey.steps.d.ts.map +1 -0
- package/dist/e2e/steps/journey.steps.js +146 -0
- package/dist/e2e/steps/journey.steps.js.map +1 -0
- package/dist/e2e/world.d.ts +76 -0
- package/dist/e2e/world.d.ts.map +1 -0
- package/dist/e2e/world.js +25 -0
- package/dist/e2e/world.js.map +1 -0
- package/features/promotions.feature +52 -0
- package/package.json +23 -5
- package/src/e2e/globs.ts +70 -0
- package/src/e2e/index.ts +16 -0
- package/src/e2e/steps/journey.steps.ts +170 -0
- package/src/e2e/world.ts +93 -0
- package/src/manifest/index.ts +12 -0
- package/src/react/discount-actions-menu.tsx +6 -0
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
/** Every packaged `.feature`, for `defineBddConfig({ features })`. */
|
|
2
|
+
export declare const discountsFeatures: string;
|
|
3
|
+
/**
|
|
4
|
+
* The base `defineBddConfig({ featuresRoot })` must be given, and it is NOT
|
|
5
|
+
* optional decoration.
|
|
6
|
+
*
|
|
7
|
+
* bddgen mirrors each feature's path RELATIVE TO `featuresRoot` under
|
|
8
|
+
* `outputDir`. Left unset it defaults to the config's own directory, so a
|
|
9
|
+
* feature living under `node_modules/...` compiles to a path Playwright then
|
|
10
|
+
* IGNORES, because its default `testIgnore` excludes `**\/node_modules/**`.
|
|
11
|
+
*
|
|
12
|
+
* The result is the worst kind of green: bddgen reports the features compiled,
|
|
13
|
+
* Playwright collects zero specs from them, and the run passes with the whole
|
|
14
|
+
* packaged suite silently absent.
|
|
15
|
+
*
|
|
16
|
+
* A host running SEVERAL packaged suites gives `featuresRoot` the directory they
|
|
17
|
+
* all sit under, since bddgen takes exactly one — and that is safe to do,
|
|
18
|
+
* because a feature outside it is a hard exit from bddgen, never a quiet
|
|
19
|
+
* omission.
|
|
20
|
+
*/
|
|
21
|
+
export declare const discountsFeaturesRoot: string;
|
|
22
|
+
/**
|
|
23
|
+
* Every packaged step definition, for `defineBddConfig({ steps })`.
|
|
24
|
+
*
|
|
25
|
+
* COMPILED JavaScript, and that is the reason this package has a build step at
|
|
26
|
+
* all while everything else it exports is raw `.ts` through the `exports` map.
|
|
27
|
+
* Those entries are consumed by an application's BUNDLER, which transpiles
|
|
28
|
+
* whatever it is pointed at. These are loaded by NODE — `playwright.config.ts`
|
|
29
|
+
* imports this module, and bddgen imports the step files — and Node refuses to
|
|
30
|
+
* strip types from anything under `node_modules`
|
|
31
|
+
* (`ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`). Playwright's own TS transform
|
|
32
|
+
* does not rescue them either; it skips `node_modules` by design.
|
|
33
|
+
*
|
|
34
|
+
* The host's own steps glob must ALSO be listed — that is where its
|
|
35
|
+
* `defineDiscountsWorld` call lives, and playwright-bdd imports every step
|
|
36
|
+
* file before any scenario runs, which is what makes the registration land in
|
|
37
|
+
* time in every worker.
|
|
38
|
+
*/
|
|
39
|
+
export declare const discountsSteps: string;
|
|
40
|
+
//# sourceMappingURL=globs.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"globs.d.ts","sourceRoot":"","sources":["../../src/e2e/globs.ts"],"names":[],"mappings":"AA6BA,sEAAsE;AACtE,eAAO,MAAM,iBAAiB,EAAE,MAAqD,CAAC;AAEtF;;;;;;;;;;;;;;;;;GAiBG;AACH,eAAO,MAAM,qBAAqB,EAAE,MAAwC,CAAC;AAE7E;;;;;;;;;;;;;;;;GAgBG;AACH,eAAO,MAAM,cAAc,EAAE,MAAsD,CAAC"}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
/**
|
|
4
|
+
* Where this package's journeys and their steps live, ON DISK, in the consumer's
|
|
5
|
+
* `node_modules`.
|
|
6
|
+
*
|
|
7
|
+
* `defineBddConfig` takes filesystem globs, not module specifiers — it hands
|
|
8
|
+
* them to a glob matcher, so `'@12-apps/discounts/features/**'` matches
|
|
9
|
+
* nothing and, worse, matches nothing SILENTLY: bddgen compiles the features it
|
|
10
|
+
* found, finds none, and the run is green with zero journeys.
|
|
11
|
+
*
|
|
12
|
+
* So the paths are RESOLVED here instead of written down by every consumer. A
|
|
13
|
+
* host that hard-codes `node_modules/@12-apps/discounts/...` is broken by
|
|
14
|
+
* pnpm's nested store, by a workspace link, and by this package's own layout
|
|
15
|
+
* changing; resolving from the package's own entry point is correct under all
|
|
16
|
+
* three.
|
|
17
|
+
*/
|
|
18
|
+
const require_ = createRequire(import.meta.url);
|
|
19
|
+
/**
|
|
20
|
+
* The package root, found from a file this package definitely exports.
|
|
21
|
+
* `./package.json` is exported precisely so this lookup needs no guess about
|
|
22
|
+
* directory depth.
|
|
23
|
+
*/
|
|
24
|
+
function packageRoot() {
|
|
25
|
+
return dirname(require_.resolve('@12-apps/discounts/package.json'));
|
|
26
|
+
}
|
|
27
|
+
/** Every packaged `.feature`, for `defineBddConfig({ features })`. */
|
|
28
|
+
export const discountsFeatures = join(packageRoot(), 'features/**/*.feature');
|
|
29
|
+
/**
|
|
30
|
+
* The base `defineBddConfig({ featuresRoot })` must be given, and it is NOT
|
|
31
|
+
* optional decoration.
|
|
32
|
+
*
|
|
33
|
+
* bddgen mirrors each feature's path RELATIVE TO `featuresRoot` under
|
|
34
|
+
* `outputDir`. Left unset it defaults to the config's own directory, so a
|
|
35
|
+
* feature living under `node_modules/...` compiles to a path Playwright then
|
|
36
|
+
* IGNORES, because its default `testIgnore` excludes `**\/node_modules/**`.
|
|
37
|
+
*
|
|
38
|
+
* The result is the worst kind of green: bddgen reports the features compiled,
|
|
39
|
+
* Playwright collects zero specs from them, and the run passes with the whole
|
|
40
|
+
* packaged suite silently absent.
|
|
41
|
+
*
|
|
42
|
+
* A host running SEVERAL packaged suites gives `featuresRoot` the directory they
|
|
43
|
+
* all sit under, since bddgen takes exactly one — and that is safe to do,
|
|
44
|
+
* because a feature outside it is a hard exit from bddgen, never a quiet
|
|
45
|
+
* omission.
|
|
46
|
+
*/
|
|
47
|
+
export const discountsFeaturesRoot = join(packageRoot(), 'features');
|
|
48
|
+
/**
|
|
49
|
+
* Every packaged step definition, for `defineBddConfig({ steps })`.
|
|
50
|
+
*
|
|
51
|
+
* COMPILED JavaScript, and that is the reason this package has a build step at
|
|
52
|
+
* all while everything else it exports is raw `.ts` through the `exports` map.
|
|
53
|
+
* Those entries are consumed by an application's BUNDLER, which transpiles
|
|
54
|
+
* whatever it is pointed at. These are loaded by NODE — `playwright.config.ts`
|
|
55
|
+
* imports this module, and bddgen imports the step files — and Node refuses to
|
|
56
|
+
* strip types from anything under `node_modules`
|
|
57
|
+
* (`ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`). Playwright's own TS transform
|
|
58
|
+
* does not rescue them either; it skips `node_modules` by design.
|
|
59
|
+
*
|
|
60
|
+
* The host's own steps glob must ALSO be listed — that is where its
|
|
61
|
+
* `defineDiscountsWorld` call lives, and playwright-bdd imports every step
|
|
62
|
+
* file before any scenario runs, which is what makes the registration land in
|
|
63
|
+
* time in every worker.
|
|
64
|
+
*/
|
|
65
|
+
export const discountsSteps = join(packageRoot(), 'dist/e2e/steps/**/*.js');
|
|
66
|
+
//# sourceMappingURL=globs.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"globs.js","sourceRoot":"","sources":["../../src/e2e/globs.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,aAAa,EAAE,MAAM,aAAa,CAAC;AAC5C,OAAO,EAAE,OAAO,EAAE,IAAI,EAAE,MAAM,WAAW,CAAC;AAE1C;;;;;;;;;;;;;;GAcG;AACH,MAAM,QAAQ,GAAG,aAAa,CAAC,MAAM,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;AAEhD;;;;GAIG;AACH,SAAS,WAAW;IAClB,OAAO,OAAO,CAAC,QAAQ,CAAC,OAAO,CAAC,iCAAiC,CAAC,CAAC,CAAC;AACtE,CAAC;AAED,sEAAsE;AACtE,MAAM,CAAC,MAAM,iBAAiB,GAAW,IAAI,CAAC,WAAW,EAAE,EAAE,uBAAuB,CAAC,CAAC;AAEtF;;;;;;;;;;;;;;;;;GAiBG;AACH,MAAM,CAAC,MAAM,qBAAqB,GAAW,IAAI,CAAC,WAAW,EAAE,EAAE,UAAU,CAAC,CAAC;AAE7E;;;;;;;;;;;;;;;;GAgBG;AACH,MAAM,CAAC,MAAM,cAAc,GAAW,IAAI,CAAC,WAAW,EAAE,EAAE,wBAAwB,CAAC,CAAC"}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@12-apps/discounts/e2e` — the packaged promotions journeys and the port a
|
|
3
|
+
* host implements to run them.
|
|
4
|
+
*
|
|
5
|
+
* The `e2e` capability the manifest declares. A host adds the three globs to
|
|
6
|
+
* its bdd config and calls `defineDiscountsWorld` from inside its own steps
|
|
7
|
+
* glob; every scenario this package ships then runs in that host, including the
|
|
8
|
+
* ones added after it integrated. Nothing is copied, so nothing can rot.
|
|
9
|
+
*/
|
|
10
|
+
export { discountsFeatures, discountsFeaturesRoot, discountsSteps } from './globs.js';
|
|
11
|
+
export { defineDiscountsWorld, discountsWorld, type DiscountsFixtures, type DiscountsWorld, } from './world.js';
|
|
12
|
+
//# sourceMappingURL=index.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/e2e/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,EAAE,iBAAiB,EAAE,qBAAqB,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AACtF,OAAO,EACL,oBAAoB,EACpB,cAAc,EACd,KAAK,iBAAiB,EACtB,KAAK,cAAc,GACpB,MAAM,YAAY,CAAC"}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@12-apps/discounts/e2e` — the packaged promotions journeys and the port a
|
|
3
|
+
* host implements to run them.
|
|
4
|
+
*
|
|
5
|
+
* The `e2e` capability the manifest declares. A host adds the three globs to
|
|
6
|
+
* its bdd config and calls `defineDiscountsWorld` from inside its own steps
|
|
7
|
+
* glob; every scenario this package ships then runs in that host, including the
|
|
8
|
+
* ones added after it integrated. Nothing is copied, so nothing can rot.
|
|
9
|
+
*/
|
|
10
|
+
export { discountsFeatures, discountsFeaturesRoot, discountsSteps } from './globs.js';
|
|
11
|
+
export { defineDiscountsWorld, discountsWorld, } from './world.js';
|
|
12
|
+
//# sourceMappingURL=index.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"index.js","sourceRoot":"","sources":["../../src/e2e/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;GAQG;AACH,OAAO,EAAE,iBAAiB,EAAE,qBAAqB,EAAE,cAAc,EAAE,MAAM,YAAY,CAAC;AACtF,OAAO,EACL,oBAAoB,EACpB,cAAc,GAGf,MAAM,YAAY,CAAC"}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"journey.steps.d.ts","sourceRoot":"","sources":["../../../src/e2e/steps/journey.steps.ts"],"names":[],"mappings":""}
|
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
import { expect } from '@playwright/test';
|
|
2
|
+
import { createBdd } from 'playwright-bdd';
|
|
3
|
+
import { discountsWorld } from '../world.js';
|
|
4
|
+
/**
|
|
5
|
+
* The packaged journeys' step definitions.
|
|
6
|
+
*
|
|
7
|
+
* Every locator is a test id THIS package's own components render, which is
|
|
8
|
+
* what makes the scenarios portable: `discounts-grid`, `discount-dialog`,
|
|
9
|
+
* `discount-action-edit`, `combo-slots` and the `total-form-field-*` family
|
|
10
|
+
* mean the same thing in any app that mounts `createWebDiscounts`. Everything a
|
|
11
|
+
* host owns — sign-in, routing, how a percentage reads — arrives through the
|
|
12
|
+
* world port rather than being written down here.
|
|
13
|
+
*
|
|
14
|
+
* ## Why `package.json` declares this file under `sideEffects`
|
|
15
|
+
*
|
|
16
|
+
* Every Given/When/Then below runs at IMPORT time — registering a step is the
|
|
17
|
+
* whole point of the module, and it exports nothing anybody imports by name. To
|
|
18
|
+
* a bundler doing tree-shaking that reads as dead weight, and dropping it is
|
|
19
|
+
* licensed: the result is a suite where bddgen reports the features compiled
|
|
20
|
+
* and every scenario then fails on an undefined step.
|
|
21
|
+
*
|
|
22
|
+
* Not one step asserts a SENTENCE. The origin host's spec did — it clicked
|
|
23
|
+
* `menuitem { name: 'Editar' }` and a `Salvar alterações` button — which is why
|
|
24
|
+
* it could only ever have run in that host: copy is required config, so the next
|
|
25
|
+
* adopter's words are different by construction and the spec would fail for a
|
|
26
|
+
* reason unrelated to promotions working.
|
|
27
|
+
*/
|
|
28
|
+
const { Given, When, Then } = createBdd();
|
|
29
|
+
const GRID = 'discounts-grid';
|
|
30
|
+
const DIALOG = 'discount-dialog';
|
|
31
|
+
/** The name the current scenario minted, so later steps can find its row. */
|
|
32
|
+
let currentName = '';
|
|
33
|
+
Given('I am signed in as somebody who may manage promotions', async ({ page }) => {
|
|
34
|
+
await discountsWorld().signInAsManager(page);
|
|
35
|
+
});
|
|
36
|
+
When('I open the promotions screen', async ({ page }) => {
|
|
37
|
+
await discountsWorld().openDiscountsScreen(page);
|
|
38
|
+
await expect(page.getByTestId(GRID)).toBeVisible();
|
|
39
|
+
});
|
|
40
|
+
Then('the promotions grid is visible', async ({ page }) => {
|
|
41
|
+
await expect(page.getByTestId(GRID)).toBeVisible();
|
|
42
|
+
});
|
|
43
|
+
/** The create dialog, open and ready to be filled. */
|
|
44
|
+
async function openCreateDialog(page) {
|
|
45
|
+
await page.getByTestId('new-discount-button').click();
|
|
46
|
+
const dialog = page.getByTestId(DIALOG);
|
|
47
|
+
await expect(dialog).toBeVisible();
|
|
48
|
+
return dialog;
|
|
49
|
+
}
|
|
50
|
+
When('I compose a percentage promotion', async ({ page }) => {
|
|
51
|
+
const { newName, percent } = discountsWorld().fixtures;
|
|
52
|
+
currentName = newName('Journey');
|
|
53
|
+
const dialog = await openCreateDialog(page);
|
|
54
|
+
await dialog.getByTestId('total-form-field-name').fill(currentName);
|
|
55
|
+
// Percentage is the default kind; clicking it anyway is the interaction that
|
|
56
|
+
// proves the toggle drives which value field is mounted.
|
|
57
|
+
await dialog.getByTestId('total-form-field-kind-item-PERCENTAGE').click();
|
|
58
|
+
await dialog.getByTestId('total-form-field-percentOff').fill(percent);
|
|
59
|
+
await dialog.getByTestId('discount-form-submit').click();
|
|
60
|
+
await expect(dialog).toBeHidden();
|
|
61
|
+
});
|
|
62
|
+
/**
|
|
63
|
+
* The row is found by NAME, and a name is the one user-visible string here that
|
|
64
|
+
* is not copy — the scenario chose it a moment ago, so naming it is naming a
|
|
65
|
+
* fixture rather than an adopter's wording.
|
|
66
|
+
*/
|
|
67
|
+
function rowFor(page, name) {
|
|
68
|
+
return page.getByTestId(GRID).getByRole('row', { name: new RegExp(name) });
|
|
69
|
+
}
|
|
70
|
+
Then('the promotion is listed with its rate', async ({ page }) => {
|
|
71
|
+
const row = rowFor(page, currentName);
|
|
72
|
+
await expect(row).toBeVisible();
|
|
73
|
+
// The round trip is the claim: a rate typed as a percentage comes back as one
|
|
74
|
+
// only if the conversion into basis points and back out BOTH ran. How it
|
|
75
|
+
// reads is the host's formatting, so the host states it.
|
|
76
|
+
await expect(row.getByText(discountsWorld().fixtures.percentInGrid)).toBeVisible();
|
|
77
|
+
});
|
|
78
|
+
When('I start composing a promotion', async ({ page }) => {
|
|
79
|
+
await openCreateDialog(page);
|
|
80
|
+
});
|
|
81
|
+
Then('the percentage field is the one on screen', async ({ page }) => {
|
|
82
|
+
await expect(page.getByTestId(DIALOG).getByTestId('total-form-field-percentOff')).toBeVisible();
|
|
83
|
+
});
|
|
84
|
+
When('I switch it to a fixed amount', async ({ page }) => {
|
|
85
|
+
await page.getByTestId(DIALOG).getByTestId('total-form-field-kind-item-FIXED_AMOUNT').click();
|
|
86
|
+
});
|
|
87
|
+
Then('the amount field REPLACES the percentage one', async ({ page }) => {
|
|
88
|
+
const dialog = page.getByTestId(DIALOG);
|
|
89
|
+
await expect(dialog.getByTestId('total-form-field-amountOff')).toBeVisible();
|
|
90
|
+
// Replaced, not hidden: a merely-hidden percentage input would still submit a
|
|
91
|
+
// stale rate alongside an amount the operator typed.
|
|
92
|
+
await expect(dialog.getByTestId('total-form-field-percentOff')).toHaveCount(0);
|
|
93
|
+
});
|
|
94
|
+
When('I switch it to a combo', async ({ page }) => {
|
|
95
|
+
await page.getByTestId(DIALOG).getByTestId('total-form-field-kind-item-COMBO').click();
|
|
96
|
+
});
|
|
97
|
+
Then('the scope question disappears and the group builder takes its place', async ({ page }) => {
|
|
98
|
+
const dialog = page.getByTestId(DIALOG);
|
|
99
|
+
// A combo COVERS the groups it is made of, so scope stops being a question at
|
|
100
|
+
// all — unmounted rather than disabled. That is what stops an operator
|
|
101
|
+
// building a combination the write path then refuses.
|
|
102
|
+
await expect(dialog.getByTestId('total-form-field-scope-item-ORDER')).toHaveCount(0);
|
|
103
|
+
await expect(dialog.getByTestId('combo-slots')).toBeVisible();
|
|
104
|
+
});
|
|
105
|
+
Then('the scope question comes back for a plain kind', async ({ page }) => {
|
|
106
|
+
const dialog = page.getByTestId(DIALOG);
|
|
107
|
+
await dialog.getByTestId('total-form-field-kind-item-FIXED_AMOUNT').click();
|
|
108
|
+
await expect(dialog.getByTestId('total-form-field-scope-item-ORDER')).toBeVisible();
|
|
109
|
+
});
|
|
110
|
+
When('I set the activation to a coupon code', async ({ page }) => {
|
|
111
|
+
await page.getByTestId(DIALOG).getByTestId('total-form-field-trigger-item-CODE').click();
|
|
112
|
+
});
|
|
113
|
+
Then('the coupon field is revealed', async ({ page }) => {
|
|
114
|
+
await expect(page.getByTestId(DIALOG).getByTestId('total-form-field-code')).toBeVisible();
|
|
115
|
+
});
|
|
116
|
+
/** Open the row's kebab and take one of its entries by its STABLE id. */
|
|
117
|
+
async function chooseRowAction(page, action) {
|
|
118
|
+
await rowFor(page, currentName).locator('[data-testid^="discount-actions-"]').click();
|
|
119
|
+
await page.getByTestId(`discount-action-${action}`).click();
|
|
120
|
+
}
|
|
121
|
+
When('I rename it from the row menu', async ({ page }) => {
|
|
122
|
+
await chooseRowAction(page, 'edit');
|
|
123
|
+
const dialog = page.getByTestId(DIALOG);
|
|
124
|
+
await expect(dialog).toBeVisible();
|
|
125
|
+
currentName = `${currentName} II`;
|
|
126
|
+
await dialog.getByTestId('total-form-field-name').fill(currentName);
|
|
127
|
+
await dialog.getByTestId('discount-form-submit').click();
|
|
128
|
+
await expect(dialog).toBeHidden();
|
|
129
|
+
});
|
|
130
|
+
Then('the grid shows the new name', async ({ page }) => {
|
|
131
|
+
// The grid re-read the server — proof the write landed, rather than proof the
|
|
132
|
+
// dialog closed.
|
|
133
|
+
await expect(rowFor(page, currentName)).toBeVisible();
|
|
134
|
+
});
|
|
135
|
+
When('I delete it from the row menu', async ({ page }) => {
|
|
136
|
+
await chooseRowAction(page, 'delete');
|
|
137
|
+
// The menu entry only OPENS the confirmation; the DELETE leaves when it is
|
|
138
|
+
// confirmed.
|
|
139
|
+
await page.getByTestId('discount-delete-confirm-confirm-button').click();
|
|
140
|
+
});
|
|
141
|
+
Then('the promotion is gone from the grid', async ({ page }) => {
|
|
142
|
+
// A soft delete: the row leaves the list even though the record survives for
|
|
143
|
+
// its redemption history.
|
|
144
|
+
await expect(rowFor(page, currentName)).toHaveCount(0);
|
|
145
|
+
});
|
|
146
|
+
//# sourceMappingURL=journey.steps.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"journey.steps.js","sourceRoot":"","sources":["../../../src/e2e/steps/journey.steps.ts"],"names":[],"mappings":"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"}
|
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { Page } from '@playwright/test';
|
|
2
|
+
/**
|
|
3
|
+
* The port a HOST implements to run the packaged promotions journeys.
|
|
4
|
+
*
|
|
5
|
+
* The journeys are portable because every assertion in them reads a test id
|
|
6
|
+
* this package's own components render — `discounts-grid`, `discount-dialog`,
|
|
7
|
+
* `discount-action-edit`, `total-form-field-*` mean the same thing in any app
|
|
8
|
+
* that mounts `createWebDiscounts`. What is NOT portable is everything around
|
|
9
|
+
* them: how an app signs somebody in, how it routes to the promotions screen,
|
|
10
|
+
* how it returns to a known state, and how it renders a percentage.
|
|
11
|
+
*
|
|
12
|
+
* It replaces a 167-line spec in the origin host (`discounts.e2e.ts`), which
|
|
13
|
+
* asserted this package's test ids from a repo it does not build — the
|
|
14
|
+
* arrangement whose failure mode is silent from here: rename an id inside the
|
|
15
|
+
* package and a spec it cannot see goes red somewhere it does not run.
|
|
16
|
+
*/
|
|
17
|
+
/**
|
|
18
|
+
* Facts about the host that the assertions have to name.
|
|
19
|
+
*
|
|
20
|
+
* A journey has to talk about SOMETHING: a permission to tick, a name to type,
|
|
21
|
+
* a rendered value to look for. None of that can live in a feature file without
|
|
22
|
+
* inventing one host's fixture as though it were everybody's.
|
|
23
|
+
*/
|
|
24
|
+
export interface DiscountsFixtures {
|
|
25
|
+
/**
|
|
26
|
+
* A name no seed uses, minted FRESH per call.
|
|
27
|
+
*
|
|
28
|
+
* A function rather than a string because these journeys WRITE: the
|
|
29
|
+
* promotions table carries a per-tenant unique index on the name, so a
|
|
30
|
+
* constant would collide with itself on the second scenario and on every
|
|
31
|
+
* re-run against the same database.
|
|
32
|
+
*/
|
|
33
|
+
newName: (prefix: string) => string;
|
|
34
|
+
/** The percentage a create journey types, as the form takes it (e.g. `15`). */
|
|
35
|
+
percent: string;
|
|
36
|
+
/**
|
|
37
|
+
* How that percentage READS in the grid once saved (e.g. `15%`).
|
|
38
|
+
*
|
|
39
|
+
* Stated by the host because formatting is the host's: `createWebDiscounts`
|
|
40
|
+
* takes a locale and a currency, so the same 15 renders differently in two
|
|
41
|
+
* adopters. It is the round-trip that matters — a value typed as a percentage
|
|
42
|
+
* comes back as one only if the conversion into basis points and back out
|
|
43
|
+
* both ran — and this is the half a package cannot spell.
|
|
44
|
+
*/
|
|
45
|
+
percentInGrid: string;
|
|
46
|
+
}
|
|
47
|
+
/** What a host must be able to do for these journeys to run in it. */
|
|
48
|
+
export interface DiscountsWorld {
|
|
49
|
+
/**
|
|
50
|
+
* Put the browser in a known signed-in state as somebody who may manage
|
|
51
|
+
* promotions, and return the store to a known one.
|
|
52
|
+
*
|
|
53
|
+
* These journeys write, so the reset is load-bearing rather than hygiene: a
|
|
54
|
+
* scenario that renamed a promotion would otherwise hand the next one a
|
|
55
|
+
* catalog it did not expect.
|
|
56
|
+
*/
|
|
57
|
+
signInAsManager(page: Page): Promise<void>;
|
|
58
|
+
/** Land on the promotions screen and wait until its grid has rendered. */
|
|
59
|
+
openDiscountsScreen(page: Page): Promise<void>;
|
|
60
|
+
fixtures: DiscountsFixtures;
|
|
61
|
+
}
|
|
62
|
+
/**
|
|
63
|
+
* Install the host's implementation. Call this from a module inside the host's
|
|
64
|
+
* OWN steps glob — playwright-bdd imports every step file before any scenario
|
|
65
|
+
* runs, so a top-level call there is registered in time, in every worker.
|
|
66
|
+
*/
|
|
67
|
+
export declare function defineDiscountsWorld(world: DiscountsWorld): void;
|
|
68
|
+
/**
|
|
69
|
+
* The installed world, or a refusal naming the fix.
|
|
70
|
+
*
|
|
71
|
+
* Throws rather than returning null: a step that ran against an absent world
|
|
72
|
+
* would fail on whatever it touched next, somewhere unrelated to the actual
|
|
73
|
+
* mistake, which is a diagnosis nobody should have to make twice.
|
|
74
|
+
*/
|
|
75
|
+
export declare function discountsWorld(): DiscountsWorld;
|
|
76
|
+
//# sourceMappingURL=world.d.ts.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"world.d.ts","sourceRoot":"","sources":["../../src/e2e/world.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,IAAI,EAAE,MAAM,kBAAkB,CAAC;AAE7C;;;;;;;;;;;;;;GAcG;AAEH;;;;;;GAMG;AACH,MAAM,WAAW,iBAAiB;IAChC;;;;;;;OAOG;IACH,OAAO,EAAE,CAAC,MAAM,EAAE,MAAM,KAAK,MAAM,CAAC;IACpC,+EAA+E;IAC/E,OAAO,EAAE,MAAM,CAAC;IAChB;;;;;;;;OAQG;IACH,aAAa,EAAE,MAAM,CAAC;CACvB;AAED,sEAAsE;AACtE,MAAM,WAAW,cAAc;IAC7B;;;;;;;OAOG;IACH,eAAe,CAAC,IAAI,EAAE,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC3C,0EAA0E;IAC1E,mBAAmB,CAAC,IAAI,EAAE,IAAI,GAAG,OAAO,CAAC,IAAI,CAAC,CAAC;IAC/C,QAAQ,EAAE,iBAAiB,CAAC;CAC7B;AAID;;;;GAIG;AACH,wBAAgB,oBAAoB,CAAC,KAAK,EAAE,cAAc,GAAG,IAAI,CAEhE;AAED;;;;;;GAMG;AACH,wBAAgB,cAAc,IAAI,cAAc,CAS/C"}
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
let installed = null;
|
|
2
|
+
/**
|
|
3
|
+
* Install the host's implementation. Call this from a module inside the host's
|
|
4
|
+
* OWN steps glob — playwright-bdd imports every step file before any scenario
|
|
5
|
+
* runs, so a top-level call there is registered in time, in every worker.
|
|
6
|
+
*/
|
|
7
|
+
export function defineDiscountsWorld(world) {
|
|
8
|
+
installed = world;
|
|
9
|
+
}
|
|
10
|
+
/**
|
|
11
|
+
* The installed world, or a refusal naming the fix.
|
|
12
|
+
*
|
|
13
|
+
* Throws rather than returning null: a step that ran against an absent world
|
|
14
|
+
* would fail on whatever it touched next, somewhere unrelated to the actual
|
|
15
|
+
* mistake, which is a diagnosis nobody should have to make twice.
|
|
16
|
+
*/
|
|
17
|
+
export function discountsWorld() {
|
|
18
|
+
if (!installed) {
|
|
19
|
+
throw new Error('No discounts e2e world is installed. Call defineDiscountsWorld({ … }) from a module ' +
|
|
20
|
+
"inside your own `steps` glob — playwright-bdd imports those before any " +
|
|
21
|
+
'scenario runs, which is what makes the registration land in every worker.');
|
|
22
|
+
}
|
|
23
|
+
return installed;
|
|
24
|
+
}
|
|
25
|
+
//# sourceMappingURL=world.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"file":"world.js","sourceRoot":"","sources":["../../src/e2e/world.ts"],"names":[],"mappings":"AAiEA,IAAI,SAAS,GAA0B,IAAI,CAAC;AAE5C;;;;GAIG;AACH,MAAM,UAAU,oBAAoB,CAAC,KAAqB;IACxD,SAAS,GAAG,KAAK,CAAC;AACpB,CAAC;AAED;;;;;;GAMG;AACH,MAAM,UAAU,cAAc;IAC5B,IAAI,CAAC,SAAS,EAAE,CAAC;QACf,MAAM,IAAI,KAAK,CACb,sFAAsF;YACpF,yEAAyE;YACzE,2EAA2E,CAC9E,CAAC;IACJ,CAAC;IACD,OAAO,SAAS,CAAC;AACnB,CAAC"}
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# The promotions admin's journeys, portable to any host that mounts
|
|
2
|
+
# `createWebDiscounts`.
|
|
3
|
+
#
|
|
4
|
+
# Every assertion reads a test id THIS package renders. Nothing here names a
|
|
5
|
+
# sentence: the origin host's spec clicked `Editar` and `Salvar alterações`,
|
|
6
|
+
# which is its pt-BR and would fail in the next adopter for a reason that has
|
|
7
|
+
# nothing to do with promotions working. What a scenario needs to NAME — a
|
|
8
|
+
# fresh promotion name, a rate, how that rate reads once saved — comes from the
|
|
9
|
+
# host's fixtures.
|
|
10
|
+
Feature: Promotions
|
|
11
|
+
|
|
12
|
+
Background:
|
|
13
|
+
Given I am signed in as somebody who may manage promotions
|
|
14
|
+
|
|
15
|
+
Scenario: The promotions grid renders
|
|
16
|
+
When I open the promotions screen
|
|
17
|
+
Then the promotions grid is visible
|
|
18
|
+
|
|
19
|
+
Scenario: A manager composes a percentage promotion and finds it in the grid
|
|
20
|
+
# The round trip is the claim: a rate typed as a percentage comes back as
|
|
21
|
+
# one only if the conversion into basis points AND back out both ran.
|
|
22
|
+
When I open the promotions screen
|
|
23
|
+
And I compose a percentage promotion
|
|
24
|
+
Then the promotion is listed with its rate
|
|
25
|
+
|
|
26
|
+
Scenario: The value field follows the kind, and scope disappears for a combo
|
|
27
|
+
# Three rules in one dialog, all of them about the form refusing to let an
|
|
28
|
+
# operator build something the write path would reject.
|
|
29
|
+
When I open the promotions screen
|
|
30
|
+
And I start composing a promotion
|
|
31
|
+
Then the percentage field is the one on screen
|
|
32
|
+
When I switch it to a fixed amount
|
|
33
|
+
Then the amount field REPLACES the percentage one
|
|
34
|
+
When I switch it to a combo
|
|
35
|
+
Then the scope question disappears and the group builder takes its place
|
|
36
|
+
And the scope question comes back for a plain kind
|
|
37
|
+
When I set the activation to a coupon code
|
|
38
|
+
Then the coupon field is revealed
|
|
39
|
+
|
|
40
|
+
Scenario: A manager renames a promotion from its row menu
|
|
41
|
+
When I open the promotions screen
|
|
42
|
+
And I compose a percentage promotion
|
|
43
|
+
And I rename it from the row menu
|
|
44
|
+
Then the grid shows the new name
|
|
45
|
+
|
|
46
|
+
Scenario: A manager deletes a promotion from its row menu
|
|
47
|
+
# Confirm-gated, and a SOFT delete — the row leaves the list while the
|
|
48
|
+
# record survives for its redemption history.
|
|
49
|
+
When I open the promotions screen
|
|
50
|
+
And I compose a percentage promotion
|
|
51
|
+
And I delete it from the row menu
|
|
52
|
+
Then the promotion is gone from the grid
|
package/package.json
CHANGED
|
@@ -1,8 +1,10 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@12-apps/discounts",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.12.0",
|
|
4
4
|
"type": "module",
|
|
5
|
-
"sideEffects":
|
|
5
|
+
"sideEffects": [
|
|
6
|
+
"**/e2e/**"
|
|
7
|
+
],
|
|
6
8
|
"description": "The discount/promotion domain: a host-agnostic evaluator (percentage or fixed amount, order/category/item scope, automatic or coupon-triggered, stacking and exclusivity, integer-cent allocation) plus the admin CRUD surface as wiring-contract route descriptors over a store port. No database, no framework, no baked user copy.",
|
|
7
9
|
"exports": {
|
|
8
10
|
".": "./src/index.ts",
|
|
@@ -18,9 +20,14 @@
|
|
|
18
20
|
"./server/en-US": "./src/server/en-US.ts",
|
|
19
21
|
"./react/en-US": "./src/react/en-US.ts",
|
|
20
22
|
"./locales": "./src/locales.ts",
|
|
21
|
-
"./package.json": "./package.json"
|
|
23
|
+
"./package.json": "./package.json",
|
|
24
|
+
"./e2e": {
|
|
25
|
+
"types": "./dist/e2e/index.d.ts",
|
|
26
|
+
"default": "./dist/e2e/index.js"
|
|
27
|
+
}
|
|
22
28
|
},
|
|
23
29
|
"scripts": {
|
|
30
|
+
"build": "tsc -p tsconfig.e2e.json",
|
|
24
31
|
"check-types": "tsc --noEmit",
|
|
25
32
|
"clean": "rm -rf node_modules coverage dist",
|
|
26
33
|
"dev": "storybook dev -p 6009",
|
|
@@ -44,7 +51,9 @@
|
|
|
44
51
|
"@12-apps/wiring": ">=1.3.0",
|
|
45
52
|
"react": ">=19.0.0",
|
|
46
53
|
"react-dom": ">=19.0.0",
|
|
47
|
-
"react-router-dom": ">=7.0.0"
|
|
54
|
+
"react-router-dom": ">=7.0.0",
|
|
55
|
+
"@playwright/test": ">=1.55.0",
|
|
56
|
+
"playwright-bdd": ">=8.3.0"
|
|
48
57
|
},
|
|
49
58
|
"peerDependenciesMeta": {
|
|
50
59
|
"@12-apps/wiring": {
|
|
@@ -58,6 +67,12 @@
|
|
|
58
67
|
},
|
|
59
68
|
"react-router-dom": {
|
|
60
69
|
"optional": true
|
|
70
|
+
},
|
|
71
|
+
"@playwright/test": {
|
|
72
|
+
"optional": true
|
|
73
|
+
},
|
|
74
|
+
"playwright-bdd": {
|
|
75
|
+
"optional": true
|
|
61
76
|
}
|
|
62
77
|
},
|
|
63
78
|
"devDependencies": {
|
|
@@ -69,6 +84,7 @@
|
|
|
69
84
|
"@emotion/react": "^11.14.0",
|
|
70
85
|
"@emotion/styled": "^11.14.0",
|
|
71
86
|
"@mui/material": "^6.5.0",
|
|
87
|
+
"@playwright/test": "^1.61.1",
|
|
72
88
|
"@storybook/addon-docs": "^9.1.10",
|
|
73
89
|
"@storybook/addon-links": "^9.1.10",
|
|
74
90
|
"@storybook/react-vite": "^9.1.10",
|
|
@@ -80,6 +96,7 @@
|
|
|
80
96
|
"eslint-plugin-storybook": "9.1.10",
|
|
81
97
|
"eslint-plugin-test-flakiness": "^1.4.0",
|
|
82
98
|
"jsdom": "^25.0.1",
|
|
99
|
+
"playwright-bdd": "^9.2.0",
|
|
83
100
|
"react": "^19.2.0",
|
|
84
101
|
"react-dom": "^19.2.0",
|
|
85
102
|
"react-router-dom": "^7.0.0",
|
|
@@ -117,7 +134,8 @@
|
|
|
117
134
|
"!**/*.stories.*",
|
|
118
135
|
"!**/*.test-story.*",
|
|
119
136
|
"!**/test-helpers.*",
|
|
120
|
-
"!**/__stories__/**"
|
|
137
|
+
"!**/__stories__/**",
|
|
138
|
+
"features"
|
|
121
139
|
],
|
|
122
140
|
"wiring": {
|
|
123
141
|
"db": {
|
package/src/e2e/globs.ts
ADDED
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { createRequire } from 'node:module';
|
|
2
|
+
import { dirname, join } from 'node:path';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Where this package's journeys and their steps live, ON DISK, in the consumer's
|
|
6
|
+
* `node_modules`.
|
|
7
|
+
*
|
|
8
|
+
* `defineBddConfig` takes filesystem globs, not module specifiers — it hands
|
|
9
|
+
* them to a glob matcher, so `'@12-apps/discounts/features/**'` matches
|
|
10
|
+
* nothing and, worse, matches nothing SILENTLY: bddgen compiles the features it
|
|
11
|
+
* found, finds none, and the run is green with zero journeys.
|
|
12
|
+
*
|
|
13
|
+
* So the paths are RESOLVED here instead of written down by every consumer. A
|
|
14
|
+
* host that hard-codes `node_modules/@12-apps/discounts/...` is broken by
|
|
15
|
+
* pnpm's nested store, by a workspace link, and by this package's own layout
|
|
16
|
+
* changing; resolving from the package's own entry point is correct under all
|
|
17
|
+
* three.
|
|
18
|
+
*/
|
|
19
|
+
const require_ = createRequire(import.meta.url);
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* The package root, found from a file this package definitely exports.
|
|
23
|
+
* `./package.json` is exported precisely so this lookup needs no guess about
|
|
24
|
+
* directory depth.
|
|
25
|
+
*/
|
|
26
|
+
function packageRoot(): string {
|
|
27
|
+
return dirname(require_.resolve('@12-apps/discounts/package.json'));
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** Every packaged `.feature`, for `defineBddConfig({ features })`. */
|
|
31
|
+
export const discountsFeatures: string = join(packageRoot(), 'features/**/*.feature');
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The base `defineBddConfig({ featuresRoot })` must be given, and it is NOT
|
|
35
|
+
* optional decoration.
|
|
36
|
+
*
|
|
37
|
+
* bddgen mirrors each feature's path RELATIVE TO `featuresRoot` under
|
|
38
|
+
* `outputDir`. Left unset it defaults to the config's own directory, so a
|
|
39
|
+
* feature living under `node_modules/...` compiles to a path Playwright then
|
|
40
|
+
* IGNORES, because its default `testIgnore` excludes `**\/node_modules/**`.
|
|
41
|
+
*
|
|
42
|
+
* The result is the worst kind of green: bddgen reports the features compiled,
|
|
43
|
+
* Playwright collects zero specs from them, and the run passes with the whole
|
|
44
|
+
* packaged suite silently absent.
|
|
45
|
+
*
|
|
46
|
+
* A host running SEVERAL packaged suites gives `featuresRoot` the directory they
|
|
47
|
+
* all sit under, since bddgen takes exactly one — and that is safe to do,
|
|
48
|
+
* because a feature outside it is a hard exit from bddgen, never a quiet
|
|
49
|
+
* omission.
|
|
50
|
+
*/
|
|
51
|
+
export const discountsFeaturesRoot: string = join(packageRoot(), 'features');
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* Every packaged step definition, for `defineBddConfig({ steps })`.
|
|
55
|
+
*
|
|
56
|
+
* COMPILED JavaScript, and that is the reason this package has a build step at
|
|
57
|
+
* all while everything else it exports is raw `.ts` through the `exports` map.
|
|
58
|
+
* Those entries are consumed by an application's BUNDLER, which transpiles
|
|
59
|
+
* whatever it is pointed at. These are loaded by NODE — `playwright.config.ts`
|
|
60
|
+
* imports this module, and bddgen imports the step files — and Node refuses to
|
|
61
|
+
* strip types from anything under `node_modules`
|
|
62
|
+
* (`ERR_UNSUPPORTED_NODE_MODULES_TYPE_STRIPPING`). Playwright's own TS transform
|
|
63
|
+
* does not rescue them either; it skips `node_modules` by design.
|
|
64
|
+
*
|
|
65
|
+
* The host's own steps glob must ALSO be listed — that is where its
|
|
66
|
+
* `defineDiscountsWorld` call lives, and playwright-bdd imports every step
|
|
67
|
+
* file before any scenario runs, which is what makes the registration land in
|
|
68
|
+
* time in every worker.
|
|
69
|
+
*/
|
|
70
|
+
export const discountsSteps: string = join(packageRoot(), 'dist/e2e/steps/**/*.js');
|
package/src/e2e/index.ts
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `@12-apps/discounts/e2e` — the packaged promotions journeys and the port a
|
|
3
|
+
* host implements to run them.
|
|
4
|
+
*
|
|
5
|
+
* The `e2e` capability the manifest declares. A host adds the three globs to
|
|
6
|
+
* its bdd config and calls `defineDiscountsWorld` from inside its own steps
|
|
7
|
+
* glob; every scenario this package ships then runs in that host, including the
|
|
8
|
+
* ones added after it integrated. Nothing is copied, so nothing can rot.
|
|
9
|
+
*/
|
|
10
|
+
export { discountsFeatures, discountsFeaturesRoot, discountsSteps } from './globs.js';
|
|
11
|
+
export {
|
|
12
|
+
defineDiscountsWorld,
|
|
13
|
+
discountsWorld,
|
|
14
|
+
type DiscountsFixtures,
|
|
15
|
+
type DiscountsWorld,
|
|
16
|
+
} from './world.js';
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { expect, type Locator, type Page } from '@playwright/test';
|
|
2
|
+
import { createBdd } from 'playwright-bdd';
|
|
3
|
+
|
|
4
|
+
import { discountsWorld } from '../world.js';
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* The packaged journeys' step definitions.
|
|
8
|
+
*
|
|
9
|
+
* Every locator is a test id THIS package's own components render, which is
|
|
10
|
+
* what makes the scenarios portable: `discounts-grid`, `discount-dialog`,
|
|
11
|
+
* `discount-action-edit`, `combo-slots` and the `total-form-field-*` family
|
|
12
|
+
* mean the same thing in any app that mounts `createWebDiscounts`. Everything a
|
|
13
|
+
* host owns — sign-in, routing, how a percentage reads — arrives through the
|
|
14
|
+
* world port rather than being written down here.
|
|
15
|
+
*
|
|
16
|
+
* ## Why `package.json` declares this file under `sideEffects`
|
|
17
|
+
*
|
|
18
|
+
* Every Given/When/Then below runs at IMPORT time — registering a step is the
|
|
19
|
+
* whole point of the module, and it exports nothing anybody imports by name. To
|
|
20
|
+
* a bundler doing tree-shaking that reads as dead weight, and dropping it is
|
|
21
|
+
* licensed: the result is a suite where bddgen reports the features compiled
|
|
22
|
+
* and every scenario then fails on an undefined step.
|
|
23
|
+
*
|
|
24
|
+
* Not one step asserts a SENTENCE. The origin host's spec did — it clicked
|
|
25
|
+
* `menuitem { name: 'Editar' }` and a `Salvar alterações` button — which is why
|
|
26
|
+
* it could only ever have run in that host: copy is required config, so the next
|
|
27
|
+
* adopter's words are different by construction and the spec would fail for a
|
|
28
|
+
* reason unrelated to promotions working.
|
|
29
|
+
*/
|
|
30
|
+
const { Given, When, Then } = createBdd();
|
|
31
|
+
|
|
32
|
+
const GRID = 'discounts-grid';
|
|
33
|
+
const DIALOG = 'discount-dialog';
|
|
34
|
+
|
|
35
|
+
/** The name the current scenario minted, so later steps can find its row. */
|
|
36
|
+
let currentName = '';
|
|
37
|
+
|
|
38
|
+
Given('I am signed in as somebody who may manage promotions', async ({ page }) => {
|
|
39
|
+
await discountsWorld().signInAsManager(page);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
When('I open the promotions screen', async ({ page }) => {
|
|
43
|
+
await discountsWorld().openDiscountsScreen(page);
|
|
44
|
+
await expect(page.getByTestId(GRID)).toBeVisible();
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
Then('the promotions grid is visible', async ({ page }) => {
|
|
48
|
+
await expect(page.getByTestId(GRID)).toBeVisible();
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
/** The create dialog, open and ready to be filled. */
|
|
52
|
+
async function openCreateDialog(page: Page): Promise<Locator> {
|
|
53
|
+
await page.getByTestId('new-discount-button').click();
|
|
54
|
+
const dialog = page.getByTestId(DIALOG);
|
|
55
|
+
await expect(dialog).toBeVisible();
|
|
56
|
+
return dialog;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
When('I compose a percentage promotion', async ({ page }) => {
|
|
60
|
+
const { newName, percent } = discountsWorld().fixtures;
|
|
61
|
+
currentName = newName('Journey');
|
|
62
|
+
const dialog = await openCreateDialog(page);
|
|
63
|
+
await dialog.getByTestId('total-form-field-name').fill(currentName);
|
|
64
|
+
// Percentage is the default kind; clicking it anyway is the interaction that
|
|
65
|
+
// proves the toggle drives which value field is mounted.
|
|
66
|
+
await dialog.getByTestId('total-form-field-kind-item-PERCENTAGE').click();
|
|
67
|
+
await dialog.getByTestId('total-form-field-percentOff').fill(percent);
|
|
68
|
+
await dialog.getByTestId('discount-form-submit').click();
|
|
69
|
+
await expect(dialog).toBeHidden();
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The row is found by NAME, and a name is the one user-visible string here that
|
|
74
|
+
* is not copy — the scenario chose it a moment ago, so naming it is naming a
|
|
75
|
+
* fixture rather than an adopter's wording.
|
|
76
|
+
*/
|
|
77
|
+
function rowFor(page: Page, name: string): Locator {
|
|
78
|
+
return page.getByTestId(GRID).getByRole('row', { name: new RegExp(name) });
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
Then('the promotion is listed with its rate', async ({ page }) => {
|
|
82
|
+
const row = rowFor(page, currentName);
|
|
83
|
+
await expect(row).toBeVisible();
|
|
84
|
+
// The round trip is the claim: a rate typed as a percentage comes back as one
|
|
85
|
+
// only if the conversion into basis points and back out BOTH ran. How it
|
|
86
|
+
// reads is the host's formatting, so the host states it.
|
|
87
|
+
await expect(row.getByText(discountsWorld().fixtures.percentInGrid)).toBeVisible();
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
When('I start composing a promotion', async ({ page }) => {
|
|
91
|
+
await openCreateDialog(page);
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
Then('the percentage field is the one on screen', async ({ page }) => {
|
|
95
|
+
await expect(page.getByTestId(DIALOG).getByTestId('total-form-field-percentOff')).toBeVisible();
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
When('I switch it to a fixed amount', async ({ page }) => {
|
|
99
|
+
await page.getByTestId(DIALOG).getByTestId('total-form-field-kind-item-FIXED_AMOUNT').click();
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
Then('the amount field REPLACES the percentage one', async ({ page }) => {
|
|
103
|
+
const dialog = page.getByTestId(DIALOG);
|
|
104
|
+
await expect(dialog.getByTestId('total-form-field-amountOff')).toBeVisible();
|
|
105
|
+
// Replaced, not hidden: a merely-hidden percentage input would still submit a
|
|
106
|
+
// stale rate alongside an amount the operator typed.
|
|
107
|
+
await expect(dialog.getByTestId('total-form-field-percentOff')).toHaveCount(0);
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
When('I switch it to a combo', async ({ page }) => {
|
|
111
|
+
await page.getByTestId(DIALOG).getByTestId('total-form-field-kind-item-COMBO').click();
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
Then('the scope question disappears and the group builder takes its place', async ({ page }) => {
|
|
115
|
+
const dialog = page.getByTestId(DIALOG);
|
|
116
|
+
// A combo COVERS the groups it is made of, so scope stops being a question at
|
|
117
|
+
// all — unmounted rather than disabled. That is what stops an operator
|
|
118
|
+
// building a combination the write path then refuses.
|
|
119
|
+
await expect(dialog.getByTestId('total-form-field-scope-item-ORDER')).toHaveCount(0);
|
|
120
|
+
await expect(dialog.getByTestId('combo-slots')).toBeVisible();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
Then('the scope question comes back for a plain kind', async ({ page }) => {
|
|
124
|
+
const dialog = page.getByTestId(DIALOG);
|
|
125
|
+
await dialog.getByTestId('total-form-field-kind-item-FIXED_AMOUNT').click();
|
|
126
|
+
await expect(dialog.getByTestId('total-form-field-scope-item-ORDER')).toBeVisible();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
When('I set the activation to a coupon code', async ({ page }) => {
|
|
130
|
+
await page.getByTestId(DIALOG).getByTestId('total-form-field-trigger-item-CODE').click();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
Then('the coupon field is revealed', async ({ page }) => {
|
|
134
|
+
await expect(page.getByTestId(DIALOG).getByTestId('total-form-field-code')).toBeVisible();
|
|
135
|
+
});
|
|
136
|
+
|
|
137
|
+
/** Open the row's kebab and take one of its entries by its STABLE id. */
|
|
138
|
+
async function chooseRowAction(page: Page, action: 'edit' | 'delete'): Promise<void> {
|
|
139
|
+
await rowFor(page, currentName).locator('[data-testid^="discount-actions-"]').click();
|
|
140
|
+
await page.getByTestId(`discount-action-${action}`).click();
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
When('I rename it from the row menu', async ({ page }) => {
|
|
144
|
+
await chooseRowAction(page, 'edit');
|
|
145
|
+
const dialog = page.getByTestId(DIALOG);
|
|
146
|
+
await expect(dialog).toBeVisible();
|
|
147
|
+
currentName = `${currentName} II`;
|
|
148
|
+
await dialog.getByTestId('total-form-field-name').fill(currentName);
|
|
149
|
+
await dialog.getByTestId('discount-form-submit').click();
|
|
150
|
+
await expect(dialog).toBeHidden();
|
|
151
|
+
});
|
|
152
|
+
|
|
153
|
+
Then('the grid shows the new name', async ({ page }) => {
|
|
154
|
+
// The grid re-read the server — proof the write landed, rather than proof the
|
|
155
|
+
// dialog closed.
|
|
156
|
+
await expect(rowFor(page, currentName)).toBeVisible();
|
|
157
|
+
});
|
|
158
|
+
|
|
159
|
+
When('I delete it from the row menu', async ({ page }) => {
|
|
160
|
+
await chooseRowAction(page, 'delete');
|
|
161
|
+
// The menu entry only OPENS the confirmation; the DELETE leaves when it is
|
|
162
|
+
// confirmed.
|
|
163
|
+
await page.getByTestId('discount-delete-confirm-confirm-button').click();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
Then('the promotion is gone from the grid', async ({ page }) => {
|
|
167
|
+
// A soft delete: the row leaves the list even though the record survives for
|
|
168
|
+
// its redemption history.
|
|
169
|
+
await expect(rowFor(page, currentName)).toHaveCount(0);
|
|
170
|
+
});
|
package/src/e2e/world.ts
ADDED
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
import type { Page } from '@playwright/test';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The port a HOST implements to run the packaged promotions journeys.
|
|
5
|
+
*
|
|
6
|
+
* The journeys are portable because every assertion in them reads a test id
|
|
7
|
+
* this package's own components render — `discounts-grid`, `discount-dialog`,
|
|
8
|
+
* `discount-action-edit`, `total-form-field-*` mean the same thing in any app
|
|
9
|
+
* that mounts `createWebDiscounts`. What is NOT portable is everything around
|
|
10
|
+
* them: how an app signs somebody in, how it routes to the promotions screen,
|
|
11
|
+
* how it returns to a known state, and how it renders a percentage.
|
|
12
|
+
*
|
|
13
|
+
* It replaces a 167-line spec in the origin host (`discounts.e2e.ts`), which
|
|
14
|
+
* asserted this package's test ids from a repo it does not build — the
|
|
15
|
+
* arrangement whose failure mode is silent from here: rename an id inside the
|
|
16
|
+
* package and a spec it cannot see goes red somewhere it does not run.
|
|
17
|
+
*/
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Facts about the host that the assertions have to name.
|
|
21
|
+
*
|
|
22
|
+
* A journey has to talk about SOMETHING: a permission to tick, a name to type,
|
|
23
|
+
* a rendered value to look for. None of that can live in a feature file without
|
|
24
|
+
* inventing one host's fixture as though it were everybody's.
|
|
25
|
+
*/
|
|
26
|
+
export interface DiscountsFixtures {
|
|
27
|
+
/**
|
|
28
|
+
* A name no seed uses, minted FRESH per call.
|
|
29
|
+
*
|
|
30
|
+
* A function rather than a string because these journeys WRITE: the
|
|
31
|
+
* promotions table carries a per-tenant unique index on the name, so a
|
|
32
|
+
* constant would collide with itself on the second scenario and on every
|
|
33
|
+
* re-run against the same database.
|
|
34
|
+
*/
|
|
35
|
+
newName: (prefix: string) => string;
|
|
36
|
+
/** The percentage a create journey types, as the form takes it (e.g. `15`). */
|
|
37
|
+
percent: string;
|
|
38
|
+
/**
|
|
39
|
+
* How that percentage READS in the grid once saved (e.g. `15%`).
|
|
40
|
+
*
|
|
41
|
+
* Stated by the host because formatting is the host's: `createWebDiscounts`
|
|
42
|
+
* takes a locale and a currency, so the same 15 renders differently in two
|
|
43
|
+
* adopters. It is the round-trip that matters — a value typed as a percentage
|
|
44
|
+
* comes back as one only if the conversion into basis points and back out
|
|
45
|
+
* both ran — and this is the half a package cannot spell.
|
|
46
|
+
*/
|
|
47
|
+
percentInGrid: string;
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
/** What a host must be able to do for these journeys to run in it. */
|
|
51
|
+
export interface DiscountsWorld {
|
|
52
|
+
/**
|
|
53
|
+
* Put the browser in a known signed-in state as somebody who may manage
|
|
54
|
+
* promotions, and return the store to a known one.
|
|
55
|
+
*
|
|
56
|
+
* These journeys write, so the reset is load-bearing rather than hygiene: a
|
|
57
|
+
* scenario that renamed a promotion would otherwise hand the next one a
|
|
58
|
+
* catalog it did not expect.
|
|
59
|
+
*/
|
|
60
|
+
signInAsManager(page: Page): Promise<void>;
|
|
61
|
+
/** Land on the promotions screen and wait until its grid has rendered. */
|
|
62
|
+
openDiscountsScreen(page: Page): Promise<void>;
|
|
63
|
+
fixtures: DiscountsFixtures;
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
let installed: DiscountsWorld | null = null;
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* Install the host's implementation. Call this from a module inside the host's
|
|
70
|
+
* OWN steps glob — playwright-bdd imports every step file before any scenario
|
|
71
|
+
* runs, so a top-level call there is registered in time, in every worker.
|
|
72
|
+
*/
|
|
73
|
+
export function defineDiscountsWorld(world: DiscountsWorld): void {
|
|
74
|
+
installed = world;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/**
|
|
78
|
+
* The installed world, or a refusal naming the fix.
|
|
79
|
+
*
|
|
80
|
+
* Throws rather than returning null: a step that ran against an absent world
|
|
81
|
+
* would fail on whatever it touched next, somewhere unrelated to the actual
|
|
82
|
+
* mistake, which is a diagnosis nobody should have to make twice.
|
|
83
|
+
*/
|
|
84
|
+
export function discountsWorld(): DiscountsWorld {
|
|
85
|
+
if (!installed) {
|
|
86
|
+
throw new Error(
|
|
87
|
+
'No discounts e2e world is installed. Call defineDiscountsWorld({ … }) from a module ' +
|
|
88
|
+
"inside your own `steps` glob — playwright-bdd imports those before any " +
|
|
89
|
+
'scenario runs, which is what makes the registration land in every worker.',
|
|
90
|
+
);
|
|
91
|
+
}
|
|
92
|
+
return installed;
|
|
93
|
+
}
|
package/src/manifest/index.ts
CHANGED
|
@@ -82,6 +82,18 @@ export const discountsManifest = {
|
|
|
82
82
|
* as the order that redeemed it is a rule that can be redeemed twice.
|
|
83
83
|
*/
|
|
84
84
|
db: { partial: "prisma/discounts.prisma", migrations: "prisma/migrations" },
|
|
85
|
+
/**
|
|
86
|
+
* The promotions journeys (`../e2e`), and the world port a host implements
|
|
87
|
+
* to run them.
|
|
88
|
+
*
|
|
89
|
+
* Undeclared, they were adopted BY CONVENTION: a 167-line spec in the origin
|
|
90
|
+
* host asserting this package's own test ids from a repo it does not build —
|
|
91
|
+
* the arrangement whose failure mode is silent from here, since renaming an
|
|
92
|
+
* id in this package goes red somewhere this package cannot see. Declared, a
|
|
93
|
+
* host either binds `featuresRoot` or declines in writing, and the decline
|
|
94
|
+
* lands in the wiring report rather than in nobody's head.
|
|
95
|
+
*/
|
|
96
|
+
e2e: { entry: "@12-apps/discounts/e2e", world: { factory: "defineDiscountsWorld" } },
|
|
85
97
|
server: ["http"],
|
|
86
98
|
web: ["surface", "areas"],
|
|
87
99
|
} as const satisfies PackageManifest;
|
|
@@ -92,17 +92,23 @@ export function DiscountActionsMenu({
|
|
|
92
92
|
|
|
93
93
|
const remove = useDeleteConfirm(row, api, copy, onError);
|
|
94
94
|
|
|
95
|
+
// Each entry carries a test id derived from its STABLE id, never its label:
|
|
96
|
+
// a label is host copy, so a spec clicking one is pinned to a single
|
|
97
|
+
// adopter's words — which is exactly what the packaged journeys exist not to
|
|
98
|
+
// be. The origin host's spec reached for `menuitem { name: 'Editar' }`.
|
|
95
99
|
const items: DropdownMenuItem[] = [];
|
|
96
100
|
if (canEdit) {
|
|
97
101
|
items.push({
|
|
98
102
|
id: "edit",
|
|
99
103
|
label: copy.actions.edit,
|
|
104
|
+
dataTestId: "discount-action-edit",
|
|
100
105
|
onClick: () => setEditOpen(true),
|
|
101
106
|
});
|
|
102
107
|
}
|
|
103
108
|
items.push({
|
|
104
109
|
id: "delete",
|
|
105
110
|
label: copy.actions.delete,
|
|
111
|
+
dataTestId: "discount-action-delete",
|
|
106
112
|
color: "danger",
|
|
107
113
|
onClick: remove.request,
|
|
108
114
|
});
|