@entifix/testing-e2e 0.1.0-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.
Files changed (63) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +150 -0
  3. package/dist/fixtures/entity-backend.d.ts +54 -0
  4. package/dist/fixtures/entity-backend.d.ts.map +1 -0
  5. package/dist/fixtures/entity-backend.js +130 -0
  6. package/dist/fixtures/entity-backend.js.map +1 -0
  7. package/dist/fixtures/entity-handlers.d.ts +32 -0
  8. package/dist/fixtures/entity-handlers.d.ts.map +1 -0
  9. package/dist/fixtures/entity-handlers.js +39 -0
  10. package/dist/fixtures/entity-handlers.js.map +1 -0
  11. package/dist/fixtures/fake-infrastructure.d.ts +61 -0
  12. package/dist/fixtures/fake-infrastructure.d.ts.map +1 -0
  13. package/dist/fixtures/fake-infrastructure.js +77 -0
  14. package/dist/fixtures/fake-infrastructure.js.map +1 -0
  15. package/dist/fixtures/index.d.ts +5 -0
  16. package/dist/fixtures/index.d.ts.map +1 -0
  17. package/dist/fixtures/index.js +6 -0
  18. package/dist/fixtures/index.js.map +1 -0
  19. package/dist/fixtures/signing-key.d.ts +27 -0
  20. package/dist/fixtures/signing-key.d.ts.map +1 -0
  21. package/dist/fixtures/signing-key.js +91 -0
  22. package/dist/fixtures/signing-key.js.map +1 -0
  23. package/dist/index.d.ts +2 -0
  24. package/dist/index.d.ts.map +1 -0
  25. package/dist/index.js +3 -0
  26. package/dist/index.js.map +1 -0
  27. package/dist/playwright/assert-expected-server.d.ts +43 -0
  28. package/dist/playwright/assert-expected-server.d.ts.map +1 -0
  29. package/dist/playwright/assert-expected-server.js +98 -0
  30. package/dist/playwright/assert-expected-server.js.map +1 -0
  31. package/dist/playwright/config.d.ts +78 -0
  32. package/dist/playwright/config.d.ts.map +1 -0
  33. package/dist/playwright/config.js +96 -0
  34. package/dist/playwright/config.js.map +1 -0
  35. package/dist/playwright/entity-table.d.ts +69 -0
  36. package/dist/playwright/entity-table.d.ts.map +1 -0
  37. package/dist/playwright/entity-table.js +129 -0
  38. package/dist/playwright/entity-table.js.map +1 -0
  39. package/dist/playwright/index.d.ts +6 -0
  40. package/dist/playwright/index.d.ts.map +1 -0
  41. package/dist/playwright/index.js +7 -0
  42. package/dist/playwright/index.js.map +1 -0
  43. package/dist/playwright/session.d.ts +85 -0
  44. package/dist/playwright/session.d.ts.map +1 -0
  45. package/dist/playwright/session.js +250 -0
  46. package/dist/playwright/session.js.map +1 -0
  47. package/dist/playwright/test.d.ts +51 -0
  48. package/dist/playwright/test.d.ts.map +1 -0
  49. package/dist/playwright/test.js +79 -0
  50. package/dist/playwright/test.js.map +1 -0
  51. package/dist/profile/profile.d.ts +43 -0
  52. package/dist/profile/profile.d.ts.map +1 -0
  53. package/dist/profile/profile.js +51 -0
  54. package/dist/profile/profile.js.map +1 -0
  55. package/dist/service/define-service-e2e.d.ts +54 -0
  56. package/dist/service/define-service-e2e.d.ts.map +1 -0
  57. package/dist/service/define-service-e2e.js +57 -0
  58. package/dist/service/define-service-e2e.js.map +1 -0
  59. package/dist/service/index.d.ts +2 -0
  60. package/dist/service/index.d.ts.map +1 -0
  61. package/dist/service/index.js +3 -0
  62. package/dist/service/index.js.map +1 -0
  63. package/package.json +131 -0
@@ -0,0 +1,129 @@
1
+ import { expect } from '@playwright/test';
2
+ /**
3
+ * A driver for the `EntityTable` organism: filters, sorting, paging and the
4
+ * rendered rows.
5
+ *
6
+ * Every entity listing in the workspace is the same component, so its selectors
7
+ * belong in one place rather than being re-declared as local helpers in each
8
+ * suite — which is what the two admin specs did, with two different sets of
9
+ * conventions for the same widget.
10
+ *
11
+ * The wide (grid) layout is what is asserted on. Both layouts always render —
12
+ * the pivot is CSS-only, so there is no SSR hydration mismatch — and the table
13
+ * is the one carrying column semantics.
14
+ */ export class EntityTablePage {
15
+ /** Rows of the wide layout. */ get rows() {
16
+ return this.page.locator('table tbody tr');
17
+ }
18
+ /** Waits until the listing has painted at least one row. */ async waitForRows() {
19
+ await expect(this.rows.first()).toBeVisible();
20
+ }
21
+ /** Column headers, in render order. */ async headers() {
22
+ return this.page.locator('table thead th').allInnerTexts();
23
+ }
24
+ /**
25
+ * The values of one column, addressed by its header label rather than by
26
+ * position: a column added to the entity — or reordered by the user's saved
27
+ * preferences — must not silently shift what a spec is asserting on.
28
+ */ async columnValues(header) {
29
+ const index = (await this.headers()).findIndex((label)=>label.trim() === header);
30
+ if (index === -1) {
31
+ throw new Error(`No column headed "${header}". Rendered columns: ${(await this.headers()).join(', ')}.`);
32
+ }
33
+ return this.page.locator(`table tbody tr td:nth-child(${index + 1})`).allInnerTexts();
34
+ }
35
+ /** The distinct values of a column, sorted — the usual assertion subject. */ async distinctColumnValues(header) {
36
+ return [
37
+ ...new Set(await this.columnValues(header))
38
+ ].sort();
39
+ }
40
+ // --- filtering ----------------------------------------------------------
41
+ async openFilters() {
42
+ await this.page.getByRole('button', {
43
+ name: 'Filtros',
44
+ exact: true
45
+ }).click();
46
+ }
47
+ /** `and` (default) or `or` across the rows of the panel. */ async matchAny(match) {
48
+ await this.page.getByLabel('Coincidir todos o cualquiera de los filtros').selectOption(match);
49
+ }
50
+ /**
51
+ * Adds a filter row and fills it in.
52
+ *
53
+ * The member is always selected explicitly, never left at the default: a new
54
+ * row defaults to the entity's *first filterable* member, so a spec relying
55
+ * on it would break the moment a member is declared ahead of that one.
56
+ */ async addFilter({ member, operator, value }) {
57
+ const index = await this.page.getByLabel('Miembro del filtro').count();
58
+ await this.page.getByRole('button', {
59
+ name: 'Añadir filtro'
60
+ }).click();
61
+ await this.page.getByLabel('Miembro del filtro').nth(index).selectOption(member);
62
+ await this.page.getByLabel('Operador del filtro').nth(index).selectOption(operator);
63
+ await this.page.getByLabel('Valor del filtro').nth(index).fill(value);
64
+ }
65
+ /** Filtering is emitted on Apply, not per keystroke — each emission is a request. */ async applyFilters() {
66
+ await this.page.getByRole('button', {
67
+ name: 'Aplicar filtros'
68
+ }).click();
69
+ }
70
+ async clearFilters() {
71
+ await this.page.getByRole('button', {
72
+ name: 'Limpiar filtros'
73
+ }).click();
74
+ }
75
+ /** Opens the panel, adds every clause, applies. */ async filterBy(...clauses) {
76
+ await this.openFilters();
77
+ for (const clause of clauses){
78
+ await this.addFilter(clause);
79
+ }
80
+ await this.applyFilters();
81
+ }
82
+ // --- sorting ------------------------------------------------------------
83
+ async openSorting() {
84
+ await this.page.getByRole('button', {
85
+ name: 'Orden',
86
+ exact: true
87
+ }).click();
88
+ }
89
+ async addSort({ member, direction = 'asc' }) {
90
+ const index = await this.page.getByLabel('Miembro de orden').count();
91
+ await this.page.getByRole('button', {
92
+ name: 'Añadir orden'
93
+ }).click();
94
+ await this.page.getByLabel('Miembro de orden').nth(index).selectOption(member);
95
+ await this.page.getByLabel('Dirección de orden').nth(index).selectOption(direction);
96
+ }
97
+ async applySorting() {
98
+ await this.page.getByRole('button', {
99
+ name: 'Aplicar orden'
100
+ }).click();
101
+ }
102
+ /** Opens the panel, adds every term in precedence order, applies. */ async sortBy(...clauses) {
103
+ await this.openSorting();
104
+ for (const clause of clauses){
105
+ await this.addSort(clause);
106
+ }
107
+ await this.applySorting();
108
+ }
109
+ // --- paging -------------------------------------------------------------
110
+ /**
111
+ * `exact` matters: Next.js' dev-tools button also matches a loose "Siguiente".
112
+ */ async nextPage() {
113
+ await this.page.getByRole('button', {
114
+ name: 'Siguiente',
115
+ exact: true
116
+ }).click();
117
+ }
118
+ async previousPage() {
119
+ await this.page.getByRole('button', {
120
+ name: 'Anterior',
121
+ exact: true
122
+ }).click();
123
+ }
124
+ constructor(page){
125
+ this.page = page;
126
+ }
127
+ }
128
+
129
+ //# sourceMappingURL=entity-table.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/playwright/entity-table.ts"],"sourcesContent":["import { expect, type Locator, type Page } from '@playwright/test';\n\n/** How a filter row is filled in. */\nexport interface FilterClause {\n member: string;\n operator: string;\n value: string;\n}\n\nexport interface SortClause {\n member: string;\n direction?: 'asc' | 'desc';\n}\n\n/**\n * A driver for the `EntityTable` organism: filters, sorting, paging and the\n * rendered rows.\n *\n * Every entity listing in the workspace is the same component, so its selectors\n * belong in one place rather than being re-declared as local helpers in each\n * suite — which is what the two admin specs did, with two different sets of\n * conventions for the same widget.\n *\n * The wide (grid) layout is what is asserted on. Both layouts always render —\n * the pivot is CSS-only, so there is no SSR hydration mismatch — and the table\n * is the one carrying column semantics.\n */\nexport class EntityTablePage {\n constructor(private readonly page: Page) {}\n\n /** Rows of the wide layout. */\n get rows(): Locator {\n return this.page.locator('table tbody tr');\n }\n\n /** Waits until the listing has painted at least one row. */\n async waitForRows(): Promise<void> {\n await expect(this.rows.first()).toBeVisible();\n }\n\n /** Column headers, in render order. */\n async headers(): Promise<string[]> {\n return this.page.locator('table thead th').allInnerTexts();\n }\n\n /**\n * The values of one column, addressed by its header label rather than by\n * position: a column added to the entity — or reordered by the user's saved\n * preferences — must not silently shift what a spec is asserting on.\n */\n async columnValues(header: string): Promise<string[]> {\n const index = (await this.headers()).findIndex(\n label => label.trim() === header,\n );\n if (index === -1) {\n throw new Error(\n `No column headed \"${header}\". Rendered columns: ${(await this.headers()).join(', ')}.`,\n );\n }\n return this.page\n .locator(`table tbody tr td:nth-child(${index + 1})`)\n .allInnerTexts();\n }\n\n /** The distinct values of a column, sorted — the usual assertion subject. */\n async distinctColumnValues(header: string): Promise<string[]> {\n return [...new Set(await this.columnValues(header))].sort();\n }\n\n // --- filtering ----------------------------------------------------------\n\n async openFilters(): Promise<void> {\n await this.page\n .getByRole('button', { name: 'Filtros', exact: true })\n .click();\n }\n\n /** `and` (default) or `or` across the rows of the panel. */\n async matchAny(match: 'and' | 'or'): Promise<void> {\n await this.page\n .getByLabel('Coincidir todos o cualquiera de los filtros')\n .selectOption(match);\n }\n\n /**\n * Adds a filter row and fills it in.\n *\n * The member is always selected explicitly, never left at the default: a new\n * row defaults to the entity's *first filterable* member, so a spec relying\n * on it would break the moment a member is declared ahead of that one.\n */\n async addFilter({ member, operator, value }: FilterClause): Promise<void> {\n const index = await this.page.getByLabel('Miembro del filtro').count();\n await this.page.getByRole('button', { name: 'Añadir filtro' }).click();\n await this.page\n .getByLabel('Miembro del filtro')\n .nth(index)\n .selectOption(member);\n await this.page\n .getByLabel('Operador del filtro')\n .nth(index)\n .selectOption(operator);\n await this.page.getByLabel('Valor del filtro').nth(index).fill(value);\n }\n\n /** Filtering is emitted on Apply, not per keystroke — each emission is a request. */\n async applyFilters(): Promise<void> {\n await this.page.getByRole('button', { name: 'Aplicar filtros' }).click();\n }\n\n async clearFilters(): Promise<void> {\n await this.page.getByRole('button', { name: 'Limpiar filtros' }).click();\n }\n\n /** Opens the panel, adds every clause, applies. */\n async filterBy(...clauses: FilterClause[]): Promise<void> {\n await this.openFilters();\n for (const clause of clauses) {\n await this.addFilter(clause);\n }\n await this.applyFilters();\n }\n\n // --- sorting ------------------------------------------------------------\n\n async openSorting(): Promise<void> {\n await this.page.getByRole('button', { name: 'Orden', exact: true }).click();\n }\n\n async addSort({ member, direction = 'asc' }: SortClause): Promise<void> {\n const index = await this.page.getByLabel('Miembro de orden').count();\n await this.page.getByRole('button', { name: 'Añadir orden' }).click();\n await this.page\n .getByLabel('Miembro de orden')\n .nth(index)\n .selectOption(member);\n await this.page\n .getByLabel('Dirección de orden')\n .nth(index)\n .selectOption(direction);\n }\n\n async applySorting(): Promise<void> {\n await this.page.getByRole('button', { name: 'Aplicar orden' }).click();\n }\n\n /** Opens the panel, adds every term in precedence order, applies. */\n async sortBy(...clauses: SortClause[]): Promise<void> {\n await this.openSorting();\n for (const clause of clauses) {\n await this.addSort(clause);\n }\n await this.applySorting();\n }\n\n // --- paging -------------------------------------------------------------\n\n /**\n * `exact` matters: Next.js' dev-tools button also matches a loose \"Siguiente\".\n */\n async nextPage(): Promise<void> {\n await this.page\n .getByRole('button', { name: 'Siguiente', exact: true })\n .click();\n }\n\n async previousPage(): Promise<void> {\n await this.page\n .getByRole('button', { name: 'Anterior', exact: true })\n .click();\n }\n}\n"],"names":["expect","EntityTablePage","rows","page","locator","waitForRows","first","toBeVisible","headers","allInnerTexts","columnValues","header","index","findIndex","label","trim","Error","join","distinctColumnValues","Set","sort","openFilters","getByRole","name","exact","click","matchAny","match","getByLabel","selectOption","addFilter","member","operator","value","count","nth","fill","applyFilters","clearFilters","filterBy","clauses","clause","openSorting","addSort","direction","applySorting","sortBy","nextPage","previousPage"],"mappings":"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"}
@@ -0,0 +1,6 @@
1
+ export * from './assert-expected-server.js';
2
+ export * from './config.js';
3
+ export * from './entity-table.js';
4
+ export * from './session.js';
5
+ export * from './test.js';
6
+ //# sourceMappingURL=index.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/playwright/index.ts"],"names":[],"mappings":"AAAA,cAAc,6BAA6B,CAAC;AAC5C,cAAc,aAAa,CAAC;AAC5B,cAAc,mBAAmB,CAAC;AAClC,cAAc,cAAc,CAAC;AAC7B,cAAc,WAAW,CAAC"}
@@ -0,0 +1,7 @@
1
+ export * from './assert-expected-server.js';
2
+ export * from './config.js';
3
+ export * from './entity-table.js';
4
+ export * from './session.js';
5
+ export * from './test.js';
6
+
7
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/playwright/index.ts"],"sourcesContent":["export * from './assert-expected-server.js';\nexport * from './config.js';\nexport * from './entity-table.js';\nexport * from './session.js';\nexport * from './test.js';\n"],"names":[],"mappings":"AAAA,cAAc,8BAA8B;AAC5C,cAAc,cAAc;AAC5B,cAAc,oBAAoB;AAClC,cAAc,eAAe;AAC7B,cAAc,YAAY"}
@@ -0,0 +1,85 @@
1
+ import type { BrowserContext } from '@playwright/test';
2
+ /**
3
+ * The cookies a signed-in session travels in — the same names
4
+ * `@entifix/core` declares, **written out rather than imported**.
5
+ *
6
+ * ⚠️ **This module must import no built package at runtime.** Nx builds its
7
+ * project graph by loading every e2e project's `playwright.config.ts` in plain
8
+ * Node — no source condition, nothing built — and that config reaches this
9
+ * file. An `import … from '@entifix/core'` here resolves core's `dist`, which a
10
+ * CI job that only lints never builds, and the whole graph fails with
11
+ * `Cannot find module …/entifix-ts-core/dist/index.js`. It passed locally only
12
+ * because a developer machine always has `dist` from a previous build.
13
+ *
14
+ * `session-cookies.spec.ts` imports core and fails if these drift, so the
15
+ * duplication is checked rather than trusted.
16
+ */
17
+ export declare const ACCESS_COOKIE = "entifix_at";
18
+ export declare const SESSION_COOKIE = "entifix_sid";
19
+ export declare const LOCALE_COOKIE = "entifix_locale";
20
+ export interface SeedSessionOptions {
21
+ /** Roles the seeded principal should carry. */
22
+ roles?: readonly string[];
23
+ /**
24
+ * The organization the seeded session acts for. Tenant-plane routes resolve
25
+ * their storage from this claim, so a spec that exercises one and omits it
26
+ * gets `409 noActiveOrganization` rather than data. Pass `null` to seed a
27
+ * session with no tenant scope on purpose — an operator, or a buyer.
28
+ */
29
+ activeOrganizationId?: string | null;
30
+ /**
31
+ * Which population the seeded principal belongs to — `customer`, `vendor` or
32
+ * `operator`. Defaults to `vendor`, matching the default organization: a
33
+ * session that acts for one is a vendor member's. A spec exercising platform
34
+ * staff passes `operator` together with `activeOrganizationId: null`.
35
+ */
36
+ partyRole?: string;
37
+ /**
38
+ * The party the seeded principal is — the `Individual` id a route scoping a
39
+ * read to its caller matches against. Defaults to the seeded vendor party, so
40
+ * the fixture keeps mirroring what a real sign-in produces.
41
+ *
42
+ * Pass `null` to seed an account with no party record, which is the case a
43
+ * scoped read must answer nothing for rather than everything.
44
+ */
45
+ partyId?: string | null;
46
+ /**
47
+ * The domains the seeded organization is provisioned for — ADR 0007's second
48
+ * ceiling, which the back-office nav reads.
49
+ *
50
+ * Defaults to the one domain `entitlementSeedData` gives the demo
51
+ * organization, so the fixture keeps mirroring what a real sign-in produces.
52
+ * Pass `[]` to seed a member of an organization provisioned for nothing;
53
+ * that is a different principal from one with `activeOrganizationId: null`,
54
+ * who is outside the ceiling rather than refused by it.
55
+ */
56
+ entitlements?: readonly string[];
57
+ /**
58
+ * Locale to pin the run to. Seeded in both profiles so a run does not depend
59
+ * on the CI machine's `Accept-Language` — without it the same spec renders
60
+ * Spanish locally and English on a differently-configured runner.
61
+ */
62
+ locale?: string;
63
+ /**
64
+ * Identifier + password for the `live` profile's real sign-in, performed
65
+ * against Zitadel's hosted login page. The default is the seeded super-admin
66
+ * and the dev password `auth-service` provisions them with.
67
+ */
68
+ identifier?: string;
69
+ password?: string;
70
+ /** Where the sign-in surface is served in `live`. */
71
+ signInAppUrl?: string;
72
+ }
73
+ /**
74
+ * Give the browser context a signed-in session before the first navigation.
75
+ *
76
+ * Profile-aware, because "being signed in" means different things in each:
77
+ * `mock` fabricates the cookie, while `live` performs a **real sign-in** through
78
+ * the provider's hosted page so the token is one auth-service actually minted
79
+ * and the downstream `requirePermission` checks are genuinely exercised.
80
+ *
81
+ * Call it before `page.goto` — an app whose middleware protects the route will
82
+ * otherwise redirect away before the spec can assert anything.
83
+ */
84
+ export declare const seedSession: (context: BrowserContext, { roles, activeOrganizationId, partyRole, partyId, entitlements, locale, identifier, password, signInAppUrl, }?: SeedSessionOptions) => Promise<void>;
85
+ //# sourceMappingURL=session.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"session.d.ts","sourceRoot":"","sources":["../../src/playwright/session.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAQ,MAAM,kBAAkB,CAAC;AAI7D;;;;;;;;;;;;;;GAcG;AACH,eAAO,MAAM,aAAa,eAAe,CAAC;AAC1C,eAAO,MAAM,cAAc,gBAAgB,CAAC;AAC5C,eAAO,MAAM,aAAa,mBAAmB,CAAC;AAgB9C,MAAM,WAAW,kBAAkB;IACjC,+CAA+C;IAC/C,KAAK,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IAC1B;;;;;OAKG;IACH,oBAAoB,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACrC;;;;;OAKG;IACH,SAAS,CAAC,EAAE,MAAM,CAAC;IACnB;;;;;;;OAOG;IACH,OAAO,CAAC,EAAE,MAAM,GAAG,IAAI,CAAC;IACxB;;;;;;;;;OASG;IACH,YAAY,CAAC,EAAE,SAAS,MAAM,EAAE,CAAC;IACjC;;;;OAIG;IACH,MAAM,CAAC,EAAE,MAAM,CAAC;IAChB;;;;OAIG;IACH,UAAU,CAAC,EAAE,MAAM,CAAC;IACpB,QAAQ,CAAC,EAAE,MAAM,CAAC;IAClB,qDAAqD;IACrD,YAAY,CAAC,EAAE,MAAM,CAAC;CACvB;AAoND;;;;;;;;;;GAUG;AACH,eAAO,MAAM,WAAW,GACtB,SAAS,cAAc,EACvB,iHAWG,kBAAuB,KACzB,OAAO,CAAC,IAAI,CAuCd,CAAC"}
@@ -0,0 +1,250 @@
1
+ import { _ as _extends } from "@swc/helpers/_/_extends";
2
+ var _process_env_BACK_OFFICE_APP_URL;
3
+ import { isMockProfile } from '../profile/profile.js';
4
+ /**
5
+ * The cookies a signed-in session travels in — the same names
6
+ * `@entifix/core` declares, **written out rather than imported**.
7
+ *
8
+ * ⚠️ **This module must import no built package at runtime.** Nx builds its
9
+ * project graph by loading every e2e project's `playwright.config.ts` in plain
10
+ * Node — no source condition, nothing built — and that config reaches this
11
+ * file. An `import … from '@entifix/core'` here resolves core's `dist`, which a
12
+ * CI job that only lints never builds, and the whole graph fails with
13
+ * `Cannot find module …/entifix-ts-core/dist/index.js`. It passed locally only
14
+ * because a developer machine always has `dist` from a previous build.
15
+ *
16
+ * `session-cookies.spec.ts` imports core and fails if these drift, so the
17
+ * duplication is checked rather than trusted.
18
+ */ export const ACCESS_COOKIE = 'entifix_at';
19
+ export const SESSION_COOKIE = 'entifix_sid';
20
+ export const LOCALE_COOKIE = 'entifix_locale';
21
+ /** Give a hosted-UI component time to hydrate before its input is retyped. */ const STEP_SETTLE_MS = 1000;
22
+ /**
23
+ * Ceiling for a single hosted-UI action. Short on purpose: an action that cannot
24
+ * complete has lost a race with a navigation, and the step machine wants to look
25
+ * again rather than sit on it until the test's own timeout fires.
26
+ */ const ACTION_TIMEOUT_MS = 10000;
27
+ /** How long a step waits for the next screen: 40 × 250ms = 10s. */ const STEP_POLLS = 40;
28
+ const STEP_POLL_MS = 250;
29
+ const base64url = (value)=>Buffer.from(value).toString('base64').replace(/\+/g, '-').replace(/\//g, '_').replace(/=+$/, '');
30
+ /**
31
+ * A structurally valid, **deliberately unsigned** access token. It satisfies
32
+ * exactly the two things the mock profile exercises — the middleware's cookie
33
+ * presence check and the server-rendered nav's unverified claim read — and
34
+ * nothing else: no service verifies it, because in `mock` the services are msw
35
+ * fixtures. Signing it would need the private key, which the suite has no
36
+ * business knowing and which is exactly what asymmetric signing exists to keep
37
+ * out of places like this.
38
+ */ const fabricateToken = (roles, activeOrganizationId, partyRole, partyId, entitlements)=>{
39
+ const header = base64url(JSON.stringify({
40
+ alg: 'RS256',
41
+ kid: 'e2e-not-a-real-key',
42
+ typ: 'JWT'
43
+ }));
44
+ const payload = base64url(JSON.stringify(_extends({
45
+ userId: 'e2e-user',
46
+ subject: 'e2e-user',
47
+ sessionId: 'e2e-session',
48
+ roles,
49
+ partyRole
50
+ }, partyId === null ? {} : {
51
+ partyId
52
+ }, activeOrganizationId === null ? {} : {
53
+ activeOrganizationId,
54
+ entitlements
55
+ }, {
56
+ exp: Math.floor(Date.now() / 1000) + 3600
57
+ })));
58
+ return `${header}.${payload}.e2e-not-a-real-signature`;
59
+ };
60
+ /**
61
+ * Fill one field of the hosted login and submit it.
62
+ *
63
+ * The retype is load-bearing, not defensive. v2 disables Continue on
64
+ * `!formState.isValid` — react-hook-form state, not the DOM value — and
65
+ * Playwright can type into the input before Next has hydrated the component.
66
+ * Those keystrokes reach the DOM and never reach the form, so the button never
67
+ * enables, and the failure screenshot shows the value sitting in the field as if
68
+ * nothing were wrong. Typing again once the component is live registers it.
69
+ *
70
+ * `pressSequentially` rather than `fill` for the same reason at a smaller scale,
71
+ * and because it is what the login's own acceptance suite uses.
72
+ *
73
+ * Every action is bounded and nothing here throws: losing a race with a
74
+ * navigation is normal (the screen this was called for is already gone), and the
75
+ * caller re-reads the route and decides what to do next. An unbounded action
76
+ * would instead hang until the whole test times out, and report the wrong step.
77
+ */ const submitHostedField = async (page, testId, value)=>{
78
+ const input = page.getByTestId(testId);
79
+ const submit = page.getByTestId('submit-button');
80
+ try {
81
+ for(let attempt = 0; attempt < 4; attempt += 1){
82
+ await input.pressSequentially(value, {
83
+ timeout: ACTION_TIMEOUT_MS
84
+ });
85
+ if (await submit.isEnabled()) break;
86
+ // The keystrokes went to a component that was not listening yet. Clear
87
+ // what the DOM kept and type it again into the hydrated one.
88
+ await page.waitForTimeout(STEP_SETTLE_MS);
89
+ await input.fill('', {
90
+ timeout: ACTION_TIMEOUT_MS
91
+ });
92
+ }
93
+ await submit.click({
94
+ timeout: ACTION_TIMEOUT_MS
95
+ });
96
+ } catch (unused) {
97
+ // Deliberately swallowed — see above.
98
+ }
99
+ };
100
+ /**
101
+ * Wait for the hosted login to actually move on.
102
+ *
103
+ * A fixed sleep after each step is a race, not a wait: when the redirect is slow
104
+ * the loop re-reads the same route and acts on a screen that is mid-navigation,
105
+ * and when it is fast the sleep is wasted. This returns as soon as the route
106
+ * changes or the session lands, whichever comes first.
107
+ */ const waitForNextStep = async (context, page, fromPath)=>{
108
+ for(let i = 0; i < STEP_POLLS; i += 1){
109
+ if ((await context.cookies()).some((c)=>c.name === ACCESS_COOKIE)) return;
110
+ if (new URL(page.url()).pathname !== fromPath) return;
111
+ await page.waitForTimeout(STEP_POLL_MS);
112
+ }
113
+ };
114
+ /**
115
+ * Sign in for real, through Zitadel's hosted login page.
116
+ *
117
+ * There is no API shortcut left and that is by design: no r10c endpoint accepts
118
+ * a password, because r10c holds none
119
+ * ([ADR 0016](../../../../../docs/adr/0016-zitadel-authenticates-r10c-authorizes.md)).
120
+ * The only way to obtain a real session is the flow a person performs, so the
121
+ * `live` profile performs it — which also means this fixture exercises the
122
+ * redirect, the PKCE exchange and the callback rather than skipping them.
123
+ *
124
+ * Written against the **v2** hosted login (`ghcr.io/zitadel/zitadel-login`,
125
+ * `infra/local/zitadel-login`). Screens are told apart by their **route**, not by
126
+ * what is on them: v2 gives each step its own path under `/ui/v2/login`, while
127
+ * its `data-testid`s are reused across screens (`reset-button` is "Reset
128
+ * password" on one and "Skip" on another). Routes are also what the login app's
129
+ * own acceptance suite drives, so they are the part of its surface most likely
130
+ * to survive a release.
131
+ */ const signInThroughHostedUi = async (context, options)=>{
132
+ const page = await context.newPage();
133
+ try {
134
+ await page.goto(`${options.signInAppUrl}/api/auth/oidc/start`);
135
+ // A step machine rather than a fixed sequence, because the hosted UI's path
136
+ // is not fixed. Four shapes a scripted login/password pair walks into:
137
+ //
138
+ // - **`/loginname`**, the identifier screen, where a cold run starts.
139
+ // - **`/password`**, after it.
140
+ // - **`/mfa/set`**, offered after the password to anyone with no enrolled
141
+ // factor. `forceMfa` is off, so it is a prompt with a skip.
142
+ // - **`/accounts`**, when the browser still holds a provider session from an
143
+ // earlier spec. There is no login field on it at all.
144
+ // - **no screen at all**, when the provider session is still live and the
145
+ // authorization completes on the redirect.
146
+ //
147
+ // The loop's exit condition is the access cookie rather than the URL,
148
+ // because that is what "signed in" actually means here and it is the only
149
+ // one of the three that a landing origin does not lie about — the callback
150
+ // lands on whichever app the redirect names.
151
+ for(let step = 0; step < 8; step += 1){
152
+ if ((await context.cookies()).some((c)=>c.name === ACCESS_COOKIE)) break;
153
+ const path = new URL(page.url()).pathname;
154
+ if (path.endsWith('/accounts')) {
155
+ await page.getByRole('link', {
156
+ name: /add another account/i
157
+ }).click({
158
+ timeout: ACTION_TIMEOUT_MS
159
+ });
160
+ await waitForNextStep(context, page, path);
161
+ continue;
162
+ }
163
+ // Skipping the enrolment offer, not declining MFA: a user who HAS a
164
+ // factor is challenged at `/otp/...` or `/u2f` instead, and those screens
165
+ // have no skip — which is why a spec needing an enrolled user must seed
166
+ // its own code.
167
+ if (path.includes('/mfa/set')) {
168
+ await page.getByTestId('reset-button').click({
169
+ timeout: ACTION_TIMEOUT_MS
170
+ });
171
+ await waitForNextStep(context, page, path);
172
+ continue;
173
+ }
174
+ if (path.endsWith('/loginname')) {
175
+ await submitHostedField(page, 'username-text-input', options.identifier);
176
+ await waitForNextStep(context, page, path);
177
+ continue;
178
+ }
179
+ if (path.endsWith('/password')) {
180
+ await submitHostedField(page, 'password-text-input', options.password);
181
+ await waitForNextStep(context, page, path);
182
+ continue;
183
+ }
184
+ // No cookie yet and on a route this fixture has not been taught — an MFA
185
+ // challenge, a password change demand, or a screen v2 grew since. Name the
186
+ // route, because a bare timeout would send the reader looking in the wrong
187
+ // place.
188
+ throw new Error(`seedSession: stuck on the hosted login at ${page.url()} ("${await page.title()}"). ` + `Route "${path}" is not one this fixture handles — a second factor, most likely.`);
189
+ }
190
+ if (!(await context.cookies()).some((c)=>c.name === ACCESS_COOKIE)) {
191
+ throw new Error(`seedSession: finished the hosted login but no ${ACCESS_COOKIE} cookie was set. Is auth-service running and seeded into Zitadel?`);
192
+ }
193
+ } finally{
194
+ await page.close();
195
+ }
196
+ };
197
+ /**
198
+ * Give the browser context a signed-in session before the first navigation.
199
+ *
200
+ * Profile-aware, because "being signed in" means different things in each:
201
+ * `mock` fabricates the cookie, while `live` performs a **real sign-in** through
202
+ * the provider's hosted page so the token is one auth-service actually minted
203
+ * and the downstream `requirePermission` checks are genuinely exercised.
204
+ *
205
+ * Call it before `page.goto` — an app whose middleware protects the route will
206
+ * otherwise redirect away before the spec can assert anything.
207
+ */ export const seedSession = async (context, { roles = [
208
+ 'user'
209
+ ], activeOrganizationId = 'e2e-organization', partyRole = 'vendor', partyId = 'party-user-2', entitlements = [
210
+ 'product-configuration-management'
211
+ ], locale = 'es', identifier = 'ada@example.com', password = 'Password123!', signInAppUrl = (_process_env_BACK_OFFICE_APP_URL = process.env['BACK_OFFICE_APP_URL']) != null ? _process_env_BACK_OFFICE_APP_URL : 'http://localhost:3001' } = {})=>{
212
+ if (!isMockProfile()) {
213
+ await signInThroughHostedUi(context, {
214
+ signInAppUrl,
215
+ identifier,
216
+ password
217
+ });
218
+ await context.addCookies([
219
+ {
220
+ name: LOCALE_COOKIE,
221
+ value: locale,
222
+ domain: 'localhost',
223
+ path: '/'
224
+ }
225
+ ]);
226
+ return;
227
+ }
228
+ await context.addCookies([
229
+ {
230
+ name: ACCESS_COOKIE,
231
+ value: fabricateToken(roles, activeOrganizationId, partyRole, partyId, entitlements),
232
+ domain: 'localhost',
233
+ path: '/'
234
+ },
235
+ {
236
+ name: SESSION_COOKIE,
237
+ value: 'e2e-session',
238
+ domain: 'localhost',
239
+ path: '/'
240
+ },
241
+ {
242
+ name: LOCALE_COOKIE,
243
+ value: locale,
244
+ domain: 'localhost',
245
+ path: '/'
246
+ }
247
+ ]);
248
+ };
249
+
250
+ //# sourceMappingURL=session.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/playwright/session.ts"],"sourcesContent":["import type { BrowserContext, Page } from '@playwright/test';\n\nimport { isMockProfile } from '../profile/profile.js';\n\n/**\n * The cookies a signed-in session travels in — the same names\n * `@entifix/core` declares, **written out rather than imported**.\n *\n * ⚠️ **This module must import no built package at runtime.** Nx builds its\n * project graph by loading every e2e project's `playwright.config.ts` in plain\n * Node — no source condition, nothing built — and that config reaches this\n * file. An `import … from '@entifix/core'` here resolves core's `dist`, which a\n * CI job that only lints never builds, and the whole graph fails with\n * `Cannot find module …/entifix-ts-core/dist/index.js`. It passed locally only\n * because a developer machine always has `dist` from a previous build.\n *\n * `session-cookies.spec.ts` imports core and fails if these drift, so the\n * duplication is checked rather than trusted.\n */\nexport const ACCESS_COOKIE = 'entifix_at';\nexport const SESSION_COOKIE = 'entifix_sid';\nexport const LOCALE_COOKIE = 'entifix_locale';\n\n/** Give a hosted-UI component time to hydrate before its input is retyped. */\nconst STEP_SETTLE_MS = 1_000;\n\n/**\n * Ceiling for a single hosted-UI action. Short on purpose: an action that cannot\n * complete has lost a race with a navigation, and the step machine wants to look\n * again rather than sit on it until the test's own timeout fires.\n */\nconst ACTION_TIMEOUT_MS = 10_000;\n\n/** How long a step waits for the next screen: 40 × 250ms = 10s. */\nconst STEP_POLLS = 40;\nconst STEP_POLL_MS = 250;\n\nexport interface SeedSessionOptions {\n /** Roles the seeded principal should carry. */\n roles?: readonly string[];\n /**\n * The organization the seeded session acts for. Tenant-plane routes resolve\n * their storage from this claim, so a spec that exercises one and omits it\n * gets `409 noActiveOrganization` rather than data. Pass `null` to seed a\n * session with no tenant scope on purpose — an operator, or a buyer.\n */\n activeOrganizationId?: string | null;\n /**\n * Which population the seeded principal belongs to — `customer`, `vendor` or\n * `operator`. Defaults to `vendor`, matching the default organization: a\n * session that acts for one is a vendor member's. A spec exercising platform\n * staff passes `operator` together with `activeOrganizationId: null`.\n */\n partyRole?: string;\n /**\n * The party the seeded principal is — the `Individual` id a route scoping a\n * read to its caller matches against. Defaults to the seeded vendor party, so\n * the fixture keeps mirroring what a real sign-in produces.\n *\n * Pass `null` to seed an account with no party record, which is the case a\n * scoped read must answer nothing for rather than everything.\n */\n partyId?: string | null;\n /**\n * The domains the seeded organization is provisioned for — ADR 0007's second\n * ceiling, which the back-office nav reads.\n *\n * Defaults to the one domain `entitlementSeedData` gives the demo\n * organization, so the fixture keeps mirroring what a real sign-in produces.\n * Pass `[]` to seed a member of an organization provisioned for nothing;\n * that is a different principal from one with `activeOrganizationId: null`,\n * who is outside the ceiling rather than refused by it.\n */\n entitlements?: readonly string[];\n /**\n * Locale to pin the run to. Seeded in both profiles so a run does not depend\n * on the CI machine's `Accept-Language` — without it the same spec renders\n * Spanish locally and English on a differently-configured runner.\n */\n locale?: string;\n /**\n * Identifier + password for the `live` profile's real sign-in, performed\n * against Zitadel's hosted login page. The default is the seeded super-admin\n * and the dev password `auth-service` provisions them with.\n */\n identifier?: string;\n password?: string;\n /** Where the sign-in surface is served in `live`. */\n signInAppUrl?: string;\n}\n\nconst base64url = (value: string): string =>\n Buffer.from(value)\n .toString('base64')\n .replace(/\\+/g, '-')\n .replace(/\\//g, '_')\n .replace(/=+$/, '');\n\n/**\n * A structurally valid, **deliberately unsigned** access token. It satisfies\n * exactly the two things the mock profile exercises — the middleware's cookie\n * presence check and the server-rendered nav's unverified claim read — and\n * nothing else: no service verifies it, because in `mock` the services are msw\n * fixtures. Signing it would need the private key, which the suite has no\n * business knowing and which is exactly what asymmetric signing exists to keep\n * out of places like this.\n */\nconst fabricateToken = (\n roles: readonly string[],\n activeOrganizationId: string | null,\n partyRole: string,\n partyId: string | null,\n entitlements: readonly string[],\n): string => {\n const header = base64url(\n JSON.stringify({ alg: 'RS256', kid: 'e2e-not-a-real-key', typ: 'JWT' }),\n );\n const payload = base64url(\n JSON.stringify({\n userId: 'e2e-user',\n subject: 'e2e-user',\n sessionId: 'e2e-session',\n roles,\n partyRole,\n ...(partyId === null ? {} : { partyId }),\n ...(activeOrganizationId === null\n ? {}\n : { activeOrganizationId, entitlements }),\n exp: Math.floor(Date.now() / 1000) + 3600,\n }),\n );\n return `${header}.${payload}.e2e-not-a-real-signature`;\n};\n\n/**\n * Fill one field of the hosted login and submit it.\n *\n * The retype is load-bearing, not defensive. v2 disables Continue on\n * `!formState.isValid` — react-hook-form state, not the DOM value — and\n * Playwright can type into the input before Next has hydrated the component.\n * Those keystrokes reach the DOM and never reach the form, so the button never\n * enables, and the failure screenshot shows the value sitting in the field as if\n * nothing were wrong. Typing again once the component is live registers it.\n *\n * `pressSequentially` rather than `fill` for the same reason at a smaller scale,\n * and because it is what the login's own acceptance suite uses.\n *\n * Every action is bounded and nothing here throws: losing a race with a\n * navigation is normal (the screen this was called for is already gone), and the\n * caller re-reads the route and decides what to do next. An unbounded action\n * would instead hang until the whole test times out, and report the wrong step.\n */\nconst submitHostedField = async (\n page: Page,\n testId: string,\n value: string,\n): Promise<void> => {\n const input = page.getByTestId(testId);\n const submit = page.getByTestId('submit-button');\n try {\n for (let attempt = 0; attempt < 4; attempt += 1) {\n await input.pressSequentially(value, { timeout: ACTION_TIMEOUT_MS });\n if (await submit.isEnabled()) break;\n // The keystrokes went to a component that was not listening yet. Clear\n // what the DOM kept and type it again into the hydrated one.\n await page.waitForTimeout(STEP_SETTLE_MS);\n await input.fill('', { timeout: ACTION_TIMEOUT_MS });\n }\n await submit.click({ timeout: ACTION_TIMEOUT_MS });\n } catch {\n // Deliberately swallowed — see above.\n }\n};\n\n/**\n * Wait for the hosted login to actually move on.\n *\n * A fixed sleep after each step is a race, not a wait: when the redirect is slow\n * the loop re-reads the same route and acts on a screen that is mid-navigation,\n * and when it is fast the sleep is wasted. This returns as soon as the route\n * changes or the session lands, whichever comes first.\n */\nconst waitForNextStep = async (\n context: BrowserContext,\n page: Page,\n fromPath: string,\n): Promise<void> => {\n for (let i = 0; i < STEP_POLLS; i += 1) {\n if ((await context.cookies()).some(c => c.name === ACCESS_COOKIE)) return;\n if (new URL(page.url()).pathname !== fromPath) return;\n await page.waitForTimeout(STEP_POLL_MS);\n }\n};\n\n/**\n * Sign in for real, through Zitadel's hosted login page.\n *\n * There is no API shortcut left and that is by design: no r10c endpoint accepts\n * a password, because r10c holds none\n * ([ADR 0016](../../../../../docs/adr/0016-zitadel-authenticates-r10c-authorizes.md)).\n * The only way to obtain a real session is the flow a person performs, so the\n * `live` profile performs it — which also means this fixture exercises the\n * redirect, the PKCE exchange and the callback rather than skipping them.\n *\n * Written against the **v2** hosted login (`ghcr.io/zitadel/zitadel-login`,\n * `infra/local/zitadel-login`). Screens are told apart by their **route**, not by\n * what is on them: v2 gives each step its own path under `/ui/v2/login`, while\n * its `data-testid`s are reused across screens (`reset-button` is \"Reset\n * password\" on one and \"Skip\" on another). Routes are also what the login app's\n * own acceptance suite drives, so they are the part of its surface most likely\n * to survive a release.\n */\nconst signInThroughHostedUi = async (\n context: BrowserContext,\n options: {\n readonly signInAppUrl: string;\n readonly identifier: string;\n readonly password: string;\n },\n): Promise<void> => {\n const page = await context.newPage();\n try {\n await page.goto(`${options.signInAppUrl}/api/auth/oidc/start`);\n\n // A step machine rather than a fixed sequence, because the hosted UI's path\n // is not fixed. Four shapes a scripted login/password pair walks into:\n //\n // - **`/loginname`**, the identifier screen, where a cold run starts.\n // - **`/password`**, after it.\n // - **`/mfa/set`**, offered after the password to anyone with no enrolled\n // factor. `forceMfa` is off, so it is a prompt with a skip.\n // - **`/accounts`**, when the browser still holds a provider session from an\n // earlier spec. There is no login field on it at all.\n // - **no screen at all**, when the provider session is still live and the\n // authorization completes on the redirect.\n //\n // The loop's exit condition is the access cookie rather than the URL,\n // because that is what \"signed in\" actually means here and it is the only\n // one of the three that a landing origin does not lie about — the callback\n // lands on whichever app the redirect names.\n for (let step = 0; step < 8; step += 1) {\n if ((await context.cookies()).some(c => c.name === ACCESS_COOKIE)) break;\n\n const path = new URL(page.url()).pathname;\n\n if (path.endsWith('/accounts')) {\n await page\n .getByRole('link', { name: /add another account/i })\n .click({ timeout: ACTION_TIMEOUT_MS });\n await waitForNextStep(context, page, path);\n continue;\n }\n\n // Skipping the enrolment offer, not declining MFA: a user who HAS a\n // factor is challenged at `/otp/...` or `/u2f` instead, and those screens\n // have no skip — which is why a spec needing an enrolled user must seed\n // its own code.\n if (path.includes('/mfa/set')) {\n await page\n .getByTestId('reset-button')\n .click({ timeout: ACTION_TIMEOUT_MS });\n await waitForNextStep(context, page, path);\n continue;\n }\n\n if (path.endsWith('/loginname')) {\n await submitHostedField(\n page,\n 'username-text-input',\n options.identifier,\n );\n await waitForNextStep(context, page, path);\n continue;\n }\n\n if (path.endsWith('/password')) {\n await submitHostedField(page, 'password-text-input', options.password);\n await waitForNextStep(context, page, path);\n continue;\n }\n\n // No cookie yet and on a route this fixture has not been taught — an MFA\n // challenge, a password change demand, or a screen v2 grew since. Name the\n // route, because a bare timeout would send the reader looking in the wrong\n // place.\n throw new Error(\n `seedSession: stuck on the hosted login at ${page.url()} (\"${await page.title()}\"). ` +\n `Route \"${path}\" is not one this fixture handles — a second factor, most likely.`,\n );\n }\n\n if (!(await context.cookies()).some(c => c.name === ACCESS_COOKIE)) {\n throw new Error(\n `seedSession: finished the hosted login but no ${ACCESS_COOKIE} cookie was set. Is auth-service running and seeded into Zitadel?`,\n );\n }\n } finally {\n await page.close();\n }\n};\n\n/**\n * Give the browser context a signed-in session before the first navigation.\n *\n * Profile-aware, because \"being signed in\" means different things in each:\n * `mock` fabricates the cookie, while `live` performs a **real sign-in** through\n * the provider's hosted page so the token is one auth-service actually minted\n * and the downstream `requirePermission` checks are genuinely exercised.\n *\n * Call it before `page.goto` — an app whose middleware protects the route will\n * otherwise redirect away before the spec can assert anything.\n */\nexport const seedSession = async (\n context: BrowserContext,\n {\n roles = ['user'],\n activeOrganizationId = 'e2e-organization',\n partyRole = 'vendor',\n partyId = 'party-user-2',\n entitlements = ['product-configuration-management'],\n locale = 'es',\n identifier = 'ada@example.com',\n password = 'Password123!',\n signInAppUrl = process.env['BACK_OFFICE_APP_URL'] ??\n 'http://localhost:3001',\n }: SeedSessionOptions = {},\n): Promise<void> => {\n if (!isMockProfile()) {\n await signInThroughHostedUi(context, {\n signInAppUrl,\n identifier,\n password,\n });\n await context.addCookies([\n { name: LOCALE_COOKIE, value: locale, domain: 'localhost', path: '/' },\n ]);\n return;\n }\n\n await context.addCookies([\n {\n name: ACCESS_COOKIE,\n value: fabricateToken(\n roles,\n activeOrganizationId,\n partyRole,\n partyId,\n entitlements,\n ),\n domain: 'localhost',\n path: '/',\n },\n {\n name: SESSION_COOKIE,\n value: 'e2e-session',\n domain: 'localhost',\n path: '/',\n },\n {\n name: LOCALE_COOKIE,\n value: locale,\n domain: 'localhost',\n path: '/',\n },\n ]);\n};\n"],"names":["process","isMockProfile","ACCESS_COOKIE","SESSION_COOKIE","LOCALE_COOKIE","STEP_SETTLE_MS","ACTION_TIMEOUT_MS","STEP_POLLS","STEP_POLL_MS","base64url","value","Buffer","from","toString","replace","fabricateToken","roles","activeOrganizationId","partyRole","partyId","entitlements","header","JSON","stringify","alg","kid","typ","payload","userId","subject","sessionId","exp","Math","floor","Date","now","submitHostedField","page","testId","input","getByTestId","submit","attempt","pressSequentially","timeout","isEnabled","waitForTimeout","fill","click","waitForNextStep","context","fromPath","i","cookies","some","c","name","URL","url","pathname","signInThroughHostedUi","options","newPage","goto","signInAppUrl","step","path","endsWith","getByRole","includes","identifier","password","Error","title","close","seedSession","locale","env","addCookies","domain"],"mappings":"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"}
@@ -0,0 +1,51 @@
1
+ import { type NetworkFixture } from '@msw/playwright';
2
+ import type { AnyHandler } from 'msw';
3
+ export interface EntifixE2eFixtures {
4
+ /** The handlers the mock profile starts from. Overridable per project. */
5
+ handlers: AnyHandler[];
6
+ /** The msw network, for per-test overrides in a `*.mock.spec.ts`. */
7
+ network: NetworkFixture;
8
+ /** Refuses a hermetic run against a reused development server. */
9
+ expectedServer: void;
10
+ }
11
+ export interface EntifixE2eTestOptions {
12
+ /**
13
+ * The mock backend. Ignored in the `live` profile, where the app talks to a
14
+ * real service.
15
+ */
16
+ handlers?: AnyHandler[];
17
+ /**
18
+ * Origins whose unstubbed requests are allowed through — in practice the app
19
+ * under test itself, which serves its own documents, RSC payloads and
20
+ * dev-tooling endpoints.
21
+ *
22
+ * Everything else must be stubbed: a request to a *service* that nobody
23
+ * handled fails the test rather than escaping to a machine that may not even
24
+ * be running.
25
+ */
26
+ passthroughOrigins?: string[];
27
+ }
28
+ /**
29
+ * Playwright's own `test`, plus the reused-server guard and nothing else.
30
+ *
31
+ * For a spec that wants no msw network and no seeded session — proving the
32
+ * unauthenticated redirect, say. It exists so that "this spec needs no
33
+ * fixtures" cannot quietly mean "this spec is not checked against the artifact
34
+ * it is running on": importing `test` straight from `@playwright/test` is what
35
+ * left the account journeys resolving a principal against a live auth-service
36
+ * while claiming to be hermetic.
37
+ */
38
+ export declare const baseTest: import("@playwright/test").TestType<import("@playwright/test").PlaywrightTestArgs & import("@playwright/test").PlaywrightTestOptions & Pick<EntifixE2eFixtures, "expectedServer">, import("@playwright/test").PlaywrightWorkerArgs & import("@playwright/test").PlaywrightWorkerOptions>;
39
+ /**
40
+ * Builds the `test` an entifix app e2e project imports.
41
+ *
42
+ * In `mock` it installs the msw handlers over Playwright's `page.route()`
43
+ * (via `@msw/playwright`), so the browser talks to the fixture backend and the
44
+ * app itself is untouched — no worker script, no `NEXT_PUBLIC_*` switch, no
45
+ * mocking code shipped in the application bundle. In `live` the fixture stands
46
+ * down and the same specs hit the real service.
47
+ */
48
+ export declare const defineEntifixE2eTest: ({ handlers, passthroughOrigins, }?: EntifixE2eTestOptions) => import("@playwright/test").TestType<import("@playwright/test").PlaywrightTestArgs & import("@playwright/test").PlaywrightTestOptions & Pick<EntifixE2eFixtures, "expectedServer"> & EntifixE2eFixtures, import("@playwright/test").PlaywrightWorkerArgs & import("@playwright/test").PlaywrightWorkerOptions>;
49
+ export type { NetworkFixture } from '@msw/playwright';
50
+ export { expect } from '@playwright/test';
51
+ //# sourceMappingURL=test.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"test.d.ts","sourceRoot":"","sources":["../../src/playwright/test.ts"],"names":[],"mappings":"AAAA,OAAO,EAAwB,KAAK,cAAc,EAAE,MAAM,iBAAiB,CAAC;AAE5E,OAAO,KAAK,EAAE,UAAU,EAAE,MAAM,KAAK,CAAC;AAKtC,MAAM,WAAW,kBAAkB;IACjC,0EAA0E;IAC1E,QAAQ,EAAE,UAAU,EAAE,CAAC;IACvB,qEAAqE;IACrE,OAAO,EAAE,cAAc,CAAC;IACxB,kEAAkE;IAClE,cAAc,EAAE,IAAI,CAAC;CACtB;AAED,MAAM,WAAW,qBAAqB;IACpC;;;OAGG;IACH,QAAQ,CAAC,EAAE,UAAU,EAAE,CAAC;IACxB;;;;;;;;OAQG;IACH,kBAAkB,CAAC,EAAE,MAAM,EAAE,CAAC;CAC/B;AAeD;;;;;;;;;GASG;AACH,eAAO,MAAM,QAAQ,0RAUpB,CAAC;AAEF;;;;;;;;GAQG;AACH,eAAO,MAAM,oBAAoB,GAAI,oCAGlC,qBAA0B,kTAkCzB,CAAC;AAEL,YAAY,EAAE,cAAc,EAAE,MAAM,iBAAiB,CAAC;AACtD,OAAO,EAAE,MAAM,EAAE,MAAM,kBAAkB,CAAC"}