@ascendenceai/cortena-extensions-e2e-kit 0.1.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 (42) hide show
  1. package/LICENSE +7 -0
  2. package/README.md +126 -0
  3. package/dist/config.d.ts +59 -0
  4. package/dist/config.js +63 -0
  5. package/dist/config.js.map +1 -0
  6. package/dist/env.d.ts +43 -0
  7. package/dist/env.js +67 -0
  8. package/dist/env.js.map +1 -0
  9. package/dist/fixtures/consent.d.ts +107 -0
  10. package/dist/fixtures/consent.js +220 -0
  11. package/dist/fixtures/consent.js.map +1 -0
  12. package/dist/fixtures/embedded.d.ts +47 -0
  13. package/dist/fixtures/embedded.js +60 -0
  14. package/dist/fixtures/embedded.js.map +1 -0
  15. package/dist/fixtures/list-state.d.ts +43 -0
  16. package/dist/fixtures/list-state.js +58 -0
  17. package/dist/fixtures/list-state.js.map +1 -0
  18. package/dist/fixtures/popup.d.ts +44 -0
  19. package/dist/fixtures/popup.js +81 -0
  20. package/dist/fixtures/popup.js.map +1 -0
  21. package/dist/fixtures/shell.d.ts +63 -0
  22. package/dist/fixtures/shell.js +96 -0
  23. package/dist/fixtures/shell.js.map +1 -0
  24. package/dist/fixtures/sign-in.d.ts +59 -0
  25. package/dist/fixtures/sign-in.js +115 -0
  26. package/dist/fixtures/sign-in.js.map +1 -0
  27. package/dist/fixtures/theme.d.ts +42 -0
  28. package/dist/fixtures/theme.js +79 -0
  29. package/dist/fixtures/theme.js.map +1 -0
  30. package/dist/index.d.ts +34 -0
  31. package/dist/index.js +25 -0
  32. package/dist/index.js.map +1 -0
  33. package/dist/locators.d.ts +210 -0
  34. package/dist/locators.js +212 -0
  35. package/dist/locators.js.map +1 -0
  36. package/dist/protocol/suite.d.ts +31 -0
  37. package/dist/protocol/suite.js +196 -0
  38. package/dist/protocol/suite.js.map +1 -0
  39. package/dist/protocol/types.d.ts +61 -0
  40. package/dist/protocol/types.js +4 -0
  41. package/dist/protocol/types.js.map +1 -0
  42. package/package.json +58 -0
@@ -0,0 +1,96 @@
1
+ import { expect } from '@playwright/test';
2
+ import { SHELL } from '../locators.js';
3
+ export function shell(page) {
4
+ const avatarTrigger = page.locator(SHELL.avatarTrigger);
5
+ const helpButton = page.locator(SHELL.helpButton);
6
+ const helpPanel = page.locator(SHELL.helpPanel);
7
+ const navDrawer = page.locator(SHELL.navDrawer);
8
+ const navToggle = page.locator(SHELL.navToggle);
9
+ return {
10
+ root: page.locator(SHELL.root),
11
+ header: page.locator(SHELL.header),
12
+ brand: page.locator(SHELL.brand),
13
+ brandName: page.locator(SHELL.brandName),
14
+ brandSquare: page.locator(SHELL.brandSquare),
15
+ brandMark: page.locator(SHELL.brandMark).first(),
16
+ nav: page.locator(SHELL.nav),
17
+ navItems: page.locator(SHELL.navItem),
18
+ navItem: (label) => page.locator(SHELL.navItem).filter({ hasText: label }),
19
+ navItemById: (id) => page.locator(SHELL.navItemById(id)),
20
+ currentNavItem: page.locator(SHELL.navItemCurrent),
21
+ navToggle,
22
+ navDrawer,
23
+ navDrawerClose: page.locator(SHELL.navDrawerClose),
24
+ main: page.locator(SHELL.main),
25
+ avatarTrigger,
26
+ menuItem: (id) => page.locator(SHELL.menuItem(id)),
27
+ cluster: page.locator(SHELL.cluster),
28
+ themeToggle: page.locator(SHELL.themeToggle),
29
+ helpButton,
30
+ helpPanel,
31
+ async openAvatarMenu() {
32
+ await avatarTrigger.click();
33
+ await expect(page.locator(SHELL.menuItem('logout'))).toBeVisible();
34
+ },
35
+ async openHelp() {
36
+ await helpButton.click();
37
+ await expect(helpPanel).toBeVisible();
38
+ },
39
+ /**
40
+ * The viewport stays narrow afterwards. Deliberate: the drawer is the last
41
+ * thing the shell spec asserts, and a helper that silently restored it
42
+ * would make a following assertion pass at a width the caller did not
43
+ * choose. Set it back explicitly if more follows.
44
+ */
45
+ async openNavDrawer() {
46
+ /*
47
+ * The drawer is not a second navigation: it is the same nav, below the
48
+ * shell's breakpoint. The viewport is narrowed here rather than in a
49
+ * third Playwright project, because two projects are already both themes
50
+ * and a third dimension would be eight runs of the six specs for one
51
+ * assertion about a focus trap.
52
+ */
53
+ await page.setViewportSize({ width: 480, height: 900 });
54
+ await expect(navToggle).toBeVisible();
55
+ await navToggle.click();
56
+ await expect(navDrawer).toBeVisible();
57
+ },
58
+ };
59
+ }
60
+ /**
61
+ * §10 / P-10, in one call: the mark and the name top-left, the destinations in
62
+ * the sidebar, **Settings under the avatar and nowhere else**, and the theme
63
+ * and help controls in the bottom-right cluster.
64
+ *
65
+ * The "and nowhere else" half is the one that decays. An extension adds a
66
+ * settings link to its sidebar because it is convenient, and the fleet stops
67
+ * having one place settings lives; asserting the absence is the only way that
68
+ * gets noticed.
69
+ */
70
+ export async function expectShellChrome(page, expectations) {
71
+ const s = shell(page);
72
+ await expect(s.root).toBeVisible();
73
+ await expect(s.brand).toBeVisible();
74
+ await expect(s.brandName).toHaveText(expectations.brandName);
75
+ await expect(s.brandSquare).toBeVisible();
76
+ for (const label of expectations.navItems) {
77
+ await expect(s.navItem(label).first()).toBeVisible();
78
+ }
79
+ // Settings is not a destination in the sidebar (§10.2, mockup decision B1).
80
+ await expect(s.nav.getByText(/^settings$/i)).toHaveCount(0);
81
+ await s.openAvatarMenu();
82
+ await expect(s.menuItem('settings')).toBeVisible();
83
+ await expect(s.menuItem('logout')).toBeVisible();
84
+ for (const id of expectations.menuItems ?? []) {
85
+ await expect(s.menuItem(id)).toBeVisible();
86
+ }
87
+ await page.keyboard.press('Escape');
88
+ // Theme then help, in that order, in the one cluster (§9.3).
89
+ await expect(s.cluster).toBeVisible();
90
+ await expect(s.themeToggle).toBeVisible();
91
+ await expect(s.helpButton).toBeVisible();
92
+ await s.openHelp();
93
+ await expect(s.helpPanel).toBeVisible();
94
+ await page.keyboard.press('Escape');
95
+ }
96
+ //# sourceMappingURL=shell.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"shell.js","sourceRoot":"","sources":["../../src/fixtures/shell.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,59 @@
1
+ import { type Locator, type Page } from '@playwright/test';
2
+ import { type E2ECredentials } from '../env.js';
3
+ export interface SignInOptions {
4
+ /** Where to land. Defaults to `/`. */
5
+ route?: string;
6
+ /** Overrides the environment, for a spec that needs a second identity. */
7
+ credentials?: E2ECredentials;
8
+ /**
9
+ * Ignore `CORTENA_E2E_TOKEN` and drive the form. The login-page spec needs
10
+ * this: a token handoff proves the store reads the URL, not that anybody can
11
+ * actually sign in.
12
+ */
13
+ useForm?: boolean;
14
+ }
15
+ /**
16
+ * Signs in against the test cortena-auth and lands on `route`.
17
+ *
18
+ * Two ways in, and the difference matters:
19
+ *
20
+ * - **A token.** `CORTENA_E2E_TOKEN` goes on the URL, which is what the app's
21
+ * auth store reads synchronously in `getInitialState()` before the first
22
+ * render (§7.3). It is the fast path and the one every spec that is not
23
+ * about signing in should take — a suite that types a password six times
24
+ * is a suite that fails six times when the password rotates.
25
+ * - **The form.** The organisation step, then the credentials step. This is
26
+ * the only thing that proves a person can get in, so the login spec uses
27
+ * it even when a token is available.
28
+ *
29
+ * The form is found through `SIGN_IN` and nowhere else: DESIGN-119 changes how
30
+ * the sign-in screen is mounted, and when it does this function keeps working
31
+ * because the shared `data-slot`s are tried first.
32
+ */
33
+ export declare function signIn(page: Page, options?: SignInOptions): Promise<void>;
34
+ /**
35
+ * The organisation step, which decides *how* the org signs in before it asks
36
+ * for anything: password, Google, or both (P-08). An extension that has no
37
+ * such step — one org, or the org on the URL — simply has no such field, and
38
+ * this returns without failing.
39
+ */
40
+ export declare function organisationStep(page: Page, orgId: string): Promise<boolean>;
41
+ /**
42
+ * Anything that means "this is the sign-in screen": the shared composition, or
43
+ * the extension's own organisation or password field.
44
+ */
45
+ export declare function signInScreen(page: Page): Locator;
46
+ /**
47
+ * Whether a sign-in screen is on the page.
48
+ *
49
+ * It **waits**, and the timeout is the whole design. Asked right after a
50
+ * navigation the answer is always "no": a single-page app answers `goto` with
51
+ * an empty document and renders afterwards. So the positive case gets a real
52
+ * timeout, and a caller asserting the *absence* of a sign-in screen passes a
53
+ * short one — having already waited for something that proves the app rendered.
54
+ */
55
+ export declare function isSignInScreen(page: Page, options?: {
56
+ timeout?: number;
57
+ }): Promise<boolean>;
58
+ /** `?token=…`, preserving whatever query the route already carried. */
59
+ export declare function withToken(route: string, token: string, refreshToken?: string): string;
@@ -0,0 +1,115 @@
1
+ import { expect } from '@playwright/test';
2
+ import { SIGN_IN } from '../locators.js';
3
+ import { readCredentials } from '../env.js';
4
+ /**
5
+ * Signs in against the test cortena-auth and lands on `route`.
6
+ *
7
+ * Two ways in, and the difference matters:
8
+ *
9
+ * - **A token.** `CORTENA_E2E_TOKEN` goes on the URL, which is what the app's
10
+ * auth store reads synchronously in `getInitialState()` before the first
11
+ * render (§7.3). It is the fast path and the one every spec that is not
12
+ * about signing in should take — a suite that types a password six times
13
+ * is a suite that fails six times when the password rotates.
14
+ * - **The form.** The organisation step, then the credentials step. This is
15
+ * the only thing that proves a person can get in, so the login spec uses
16
+ * it even when a token is available.
17
+ *
18
+ * The form is found through `SIGN_IN` and nowhere else: DESIGN-119 changes how
19
+ * the sign-in screen is mounted, and when it does this function keeps working
20
+ * because the shared `data-slot`s are tried first.
21
+ */
22
+ export async function signIn(page, options = {}) {
23
+ const credentials = options.credentials ?? readCredentials();
24
+ const route = options.route ?? '/';
25
+ if (credentials.token && !options.useForm) {
26
+ await page.goto(withToken(route, credentials.token));
27
+ return;
28
+ }
29
+ if (!credentials.email || !credentials.password) {
30
+ throw new Error('signIn({ useForm: true }) needs CORTENA_E2E_USER_EMAIL and CORTENA_E2E_USER_PASSWORD. '
31
+ + 'A token proves the URL handoff works; only the form proves a person can sign in.');
32
+ }
33
+ await page.goto(route);
34
+ await organisationStep(page, credentials.orgId);
35
+ await credentialsStep(page, credentials.email, credentials.password);
36
+ }
37
+ /**
38
+ * The organisation step, which decides *how* the org signs in before it asks
39
+ * for anything: password, Google, or both (P-08). An extension that has no
40
+ * such step — one org, or the org on the URL — simply has no such field, and
41
+ * this returns without failing.
42
+ */
43
+ export async function organisationStep(page, orgId) {
44
+ /*
45
+ * Waited for, not counted. A single-page app answers `goto` with an empty
46
+ * document and renders afterwards, so a `count()` taken the instant the
47
+ * navigation resolves is a count of nothing — which reads as "this extension
48
+ * has no organisation step" and then fails four assertions later on
49
+ * something unrelated.
50
+ */
51
+ if (!(await isSignInScreen(page))) {
52
+ throw new Error('organisationStep: no sign-in screen appeared. Either the app did not render, or it is '
53
+ + 'already signed in.');
54
+ }
55
+ const field = page.getByLabel(SIGN_IN.own.orgLabel);
56
+ if (!(await field.isVisible().catch(() => false)))
57
+ return false;
58
+ await field.fill(orgId);
59
+ await page.getByRole('button', { name: SIGN_IN.own.orgSubmit }).click();
60
+ return true;
61
+ }
62
+ async function credentialsStep(page, email, password) {
63
+ const emailField = page.getByLabel(SIGN_IN.own.emailLabel);
64
+ await emailField.waitFor({ state: 'visible' });
65
+ await emailField.fill(email);
66
+ await page.getByLabel(SIGN_IN.own.passwordLabel).fill(password);
67
+ const shared = page.locator(SIGN_IN.shared.submit);
68
+ if (await shared.isVisible().catch(() => false))
69
+ await shared.click();
70
+ else
71
+ await page.getByRole('button', { name: SIGN_IN.own.submit }).click();
72
+ /*
73
+ * Signed in is the *absence* of the form, not the presence of a heading: the
74
+ * heading is the extension's and the kit does not know it. An error banner is
75
+ * asserted against separately so a wrong password reports itself rather than
76
+ * timing out on something invisible.
77
+ */
78
+ await expect(page.locator(SIGN_IN.shared.error)).toHaveCount(0);
79
+ await expect(page.getByLabel(SIGN_IN.own.passwordLabel)).toHaveCount(0, { timeout: 30_000 });
80
+ }
81
+ /**
82
+ * Anything that means "this is the sign-in screen": the shared composition, or
83
+ * the extension's own organisation or password field.
84
+ */
85
+ export function signInScreen(page) {
86
+ return page
87
+ .locator(SIGN_IN.shared.screen)
88
+ .or(page.locator(SIGN_IN.shared.form))
89
+ .or(page.getByLabel(SIGN_IN.own.orgLabel))
90
+ .or(page.getByLabel(SIGN_IN.own.passwordLabel));
91
+ }
92
+ /**
93
+ * Whether a sign-in screen is on the page.
94
+ *
95
+ * It **waits**, and the timeout is the whole design. Asked right after a
96
+ * navigation the answer is always "no": a single-page app answers `goto` with
97
+ * an empty document and renders afterwards. So the positive case gets a real
98
+ * timeout, and a caller asserting the *absence* of a sign-in screen passes a
99
+ * short one — having already waited for something that proves the app rendered.
100
+ */
101
+ export async function isSignInScreen(page, options = {}) {
102
+ try {
103
+ await signInScreen(page).first().waitFor({ state: 'visible', timeout: options.timeout ?? 15_000 });
104
+ return true;
105
+ }
106
+ catch {
107
+ return false;
108
+ }
109
+ }
110
+ /** `?token=…`, preserving whatever query the route already carried. */
111
+ export function withToken(route, token, refreshToken = 'none') {
112
+ const separator = route.includes('?') ? '&' : '?';
113
+ return `${route}${separator}token=${encodeURIComponent(token)}&refreshToken=${encodeURIComponent(refreshToken)}`;
114
+ }
115
+ //# sourceMappingURL=sign-in.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sign-in.js","sourceRoot":"","sources":["../../src/fixtures/sign-in.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,42 @@
1
+ import { type Page } from '@playwright/test';
2
+ export type ThemeMode = 'light' | 'dark';
3
+ /**
4
+ * `attribute` — the user chose, so `data-theme` is written on `<html>`.
5
+ * `os` — the user did not, so there is no attribute and the painted
6
+ * theme comes from `prefers-color-scheme`.
7
+ */
8
+ export type ThemePath = 'attribute' | 'os';
9
+ /**
10
+ * Puts the page in a theme, and asserts it got there.
11
+ *
12
+ * cortena-design drives dark mode off `data-theme` on `<html>` and falls back
13
+ * to `prefers-color-scheme` when the attribute is **absent**. Those are two
14
+ * different code paths and a suite that only ever writes the attribute never
15
+ * exercises the one a first-time visitor takes — which is how a fleet ends up
16
+ * with an extension that is correct in dark mode only for people who once
17
+ * pressed the toggle.
18
+ *
19
+ * So `theme()` does both:
20
+ *
21
+ * theme(page, 'dark') // the stored choice, data-theme="dark"
22
+ * theme(page, 'dark', { path: 'os' }) // nothing stored, OS says dark
23
+ *
24
+ * Call it **before** the first navigation: the choice is read out of
25
+ * localStorage before the first paint, so writing it afterwards proves only
26
+ * that a re-render happens.
27
+ */
28
+ export declare function theme(page: Page, mode: ThemeMode, options?: {
29
+ path?: ThemePath;
30
+ storageKey?: string;
31
+ }): Promise<void>;
32
+ /**
33
+ * What is actually painted, and how it got there.
34
+ *
35
+ * On the attribute path `<html data-theme>` says the mode outright. On the OS
36
+ * path the attribute is absent by design, so the evidence is the toggle's
37
+ * `data-theme-resolved` — which is `useCortenaTheme`'s own answer to "what am
38
+ * I painting", published for exactly this.
39
+ */
40
+ export declare function expectTheme(page: Page, mode: ThemeMode, path?: ThemePath): Promise<void>;
41
+ /** The mode the project name encodes — `chromium-dark-1280` is the dark project. */
42
+ export declare function themeFromProjectName(name: string): ThemeMode;
@@ -0,0 +1,79 @@
1
+ import { expect } from '@playwright/test';
2
+ import { SHELL, THEME } from '../locators.js';
3
+ /**
4
+ * Puts the page in a theme, and asserts it got there.
5
+ *
6
+ * cortena-design drives dark mode off `data-theme` on `<html>` and falls back
7
+ * to `prefers-color-scheme` when the attribute is **absent**. Those are two
8
+ * different code paths and a suite that only ever writes the attribute never
9
+ * exercises the one a first-time visitor takes — which is how a fleet ends up
10
+ * with an extension that is correct in dark mode only for people who once
11
+ * pressed the toggle.
12
+ *
13
+ * So `theme()` does both:
14
+ *
15
+ * theme(page, 'dark') // the stored choice, data-theme="dark"
16
+ * theme(page, 'dark', { path: 'os' }) // nothing stored, OS says dark
17
+ *
18
+ * Call it **before** the first navigation: the choice is read out of
19
+ * localStorage before the first paint, so writing it afterwards proves only
20
+ * that a re-render happens.
21
+ */
22
+ export async function theme(page, mode, options = {}) {
23
+ const path = options.path ?? 'attribute';
24
+ const storageKey = options.storageKey ?? THEME.storageKey;
25
+ if (path === 'os') {
26
+ /*
27
+ * "The OS says dark" means the *stored choice is absent*. Leaving a
28
+ * previous spec's choice behind would make this assert the attribute path
29
+ * a second time under a different name.
30
+ */
31
+ await page.addInitScript((key) => {
32
+ try {
33
+ window.localStorage.removeItem(key);
34
+ }
35
+ catch { /* a blocked store is still no choice */ }
36
+ }, storageKey);
37
+ await page.emulateMedia({ colorScheme: mode });
38
+ return;
39
+ }
40
+ await page.addInitScript(([key, value]) => {
41
+ try {
42
+ window.localStorage.setItem(key, value);
43
+ }
44
+ catch { /* see above */ }
45
+ }, [storageKey, mode]);
46
+ /*
47
+ * The OS is set to the *opposite*. If the attribute path silently stopped
48
+ * working, a page whose OS agreed with the stored choice would still look
49
+ * right and the assertion below would pass for the wrong reason.
50
+ */
51
+ await page.emulateMedia({ colorScheme: mode === 'dark' ? 'light' : 'dark' });
52
+ }
53
+ /**
54
+ * What is actually painted, and how it got there.
55
+ *
56
+ * On the attribute path `<html data-theme>` says the mode outright. On the OS
57
+ * path the attribute is absent by design, so the evidence is the toggle's
58
+ * `data-theme-resolved` — which is `useCortenaTheme`'s own answer to "what am
59
+ * I painting", published for exactly this.
60
+ */
61
+ export async function expectTheme(page, mode, path = 'attribute') {
62
+ const html = page.locator('html');
63
+ if (path === 'attribute') {
64
+ await expect(html).toHaveAttribute(THEME.attribute, mode);
65
+ }
66
+ else {
67
+ await expect(html).not.toHaveAttribute(THEME.attribute, /light|dark/);
68
+ }
69
+ const toggle = page.locator(SHELL.themeToggle);
70
+ if ((await toggle.count()) > 0) {
71
+ await expect(toggle.first()).toHaveAttribute(THEME.resolvedAttribute, mode);
72
+ await expect(toggle.first()).toHaveAttribute(THEME.choiceAttribute, path === 'attribute' ? mode : 'system');
73
+ }
74
+ }
75
+ /** The mode the project name encodes — `chromium-dark-1280` is the dark project. */
76
+ export function themeFromProjectName(name) {
77
+ return /dark/i.test(name) ? 'dark' : 'light';
78
+ }
79
+ //# sourceMappingURL=theme.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"theme.js","sourceRoot":"","sources":["../../src/fixtures/theme.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAa,MAAM,kBAAkB,CAAC;AACrD,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,gBAAgB,CAAC;AAU9C;;;;;;;;;;;;;;;;;;GAkBG;AACH,MAAM,CAAC,KAAK,UAAU,KAAK,CACzB,IAAU,EACV,IAAe,EACf,UAAqD,EAAE;IAEvD,MAAM,IAAI,GAAG,OAAO,CAAC,IAAI,IAAI,WAAW,CAAC;IACzC,MAAM,UAAU,GAAG,OAAO,CAAC,UAAU,IAAI,KAAK,CAAC,UAAU,CAAC;IAE1D,IAAI,IAAI,KAAK,IAAI,EAAE,CAAC;QAClB;;;;WAIG;QACH,MAAM,IAAI,CAAC,aAAa,CAAC,CAAC,GAAG,EAAE,EAAE;YAC/B,IAAI,CAAC;gBAAC,MAAM,CAAC,YAAY,CAAC,UAAU,CAAC,GAAG,CAAC,CAAC;YAAC,CAAC;YAAC,MAAM,CAAC,CAAC,wCAAwC,CAAC,CAAC;QACjG,CAAC,EAAE,UAAU,CAAC,CAAC;QACf,MAAM,IAAI,CAAC,YAAY,CAAC,EAAE,WAAW,EAAE,IAAI,EAAE,CAAC,CAAC;QAC/C,OAAO;IACT,CAAC;IAED,MAAM,IAAI,CAAC,aAAa,CACtB,CAAC,CAAC,GAAG,EAAE,KAAK,CAAC,EAAE,EAAE;QACf,IAAI,CAAC;YAAC,MAAM,CAAC,YAAY,CAAC,OAAO,CAAC,GAAa,EAAE,KAAe,CAAC,CAAC;QAAC,CAAC;QAAC,MAAM,CAAC,CAAC,eAAe,CAAC,CAAC;IAChG,CAAC,EACD,CAAC,UAAU,EAAE,IAAI,CAAU,CAC5B,CAAC;IACF;;;;OAIG;IACH,MAAM,IAAI,CAAC,YAAY,CAAC,EAAE,WAAW,EAAE,IAAI,KAAK,MAAM,CAAC,CAAC,CAAC,OAAO,CAAC,CAAC,CAAC,MAAM,EAAE,CAAC,CAAC;AAC/E,CAAC;AAED;;;;;;;GAOG;AACH,MAAM,CAAC,KAAK,UAAU,WAAW,CAAC,IAAU,EAAE,IAAe,EAAE,OAAkB,WAAW;IAC1F,MAAM,IAAI,GAAG,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC;IAClC,IAAI,IAAI,KAAK,WAAW,EAAE,CAAC;QACzB,MAAM,MAAM,CAAC,IAAI,CAAC,CAAC,eAAe,CAAC,KAAK,CAAC,SAAS,EAAE,IAAI,CAAC,CAAC;IAC5D,CAAC;SAAM,CAAC;QACN,MAAM,MAAM,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,eAAe,CAAC,KAAK,CAAC,SAAS,EAAE,YAAY,CAAC,CAAC;IACxE,CAAC;IAED,MAAM,MAAM,GAAG,IAAI,CAAC,OAAO,CAAC,KAAK,CAAC,WAAW,CAAC,CAAC;IAC/C,IAAI,CAAC,MAAM,MAAM,CAAC,KAAK,EAAE,CAAC,GAAG,CAAC,EAAE,CAAC;QAC/B,MAAM,MAAM,CAAC,MAAM,CAAC,KAAK,EAAE,CAAC,CAAC,eAAe,CAAC,KAAK,CAAC,iBAAiB,EAAE,IAAI,CAAC,CAAC;QAC5E,MAAM,MAAM,CAAC,MAAM,CAAC,KAAK,EAAE,CAAC,CAAC,eAAe,CAC1C,KAAK,CAAC,eAAe,EACrB,IAAI,KAAK,WAAW,CAAC,CAAC,CAAC,IAAI,CAAC,CAAC,CAAC,QAAQ,CACvC,CAAC;IACJ,CAAC;AACH,CAAC;AAED,oFAAoF;AACpF,MAAM,UAAU,oBAAoB,CAAC,IAAY;IAC/C,OAAO,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC,CAAC,CAAC,MAAM,CAAC,CAAC,CAAC,OAAO,CAAC;AAC/C,CAAC"}
@@ -0,0 +1,34 @@
1
+ /**
2
+ * The shared Playwright layer-3 kit — HOW-TO §23.4, audit rule P-36.
3
+ *
4
+ * Six extensions of seven had no end-to-end suite at all, because §23.4 names
5
+ * six flows that are the same for every one of them and each would have had to
6
+ * write all six from scratch: a sign-in helper, a theme switcher, a way to find
7
+ * the shell, a way to drive the agent pop-up. This is those six, written once.
8
+ * An extension supplies a config file and its own six values.
9
+ *
10
+ * Read `README.md` for the values. `locators.ts` is the file a cortena-ui
11
+ * change breaks, on purpose: once, here, rather than in six suites.
12
+ */
13
+ export { readCredentials, hasCredentials, environmentName, MissingCredentialsError } from './env.js';
14
+ export type { E2ECredentials } from './env.js';
15
+ export { AGENT, CONSENT, CORTENA_UI, SESSION, SHELL, SIGN_IN, THEME } from './locators.js';
16
+ export { signIn, organisationStep, isSignInScreen, signInScreen, withToken } from './fixtures/sign-in.js';
17
+ export type { SignInOptions } from './fixtures/sign-in.js';
18
+ export { embedded, postHostTheme, expectTokenStrippedFromUrl } from './fixtures/embedded.js';
19
+ export type { EmbeddedOptions } from './fixtures/embedded.js';
20
+ export { theme, expectTheme, themeFromProjectName } from './fixtures/theme.js';
21
+ export type { ThemeMode, ThemePath } from './fixtures/theme.js';
22
+ export { shell, expectShellChrome } from './fixtures/shell.js';
23
+ export type { Shell, ShellExpectations } from './fixtures/shell.js';
24
+ export { popup, expectSessionResumes } from './fixtures/popup.js';
25
+ export type { Popup } from './fixtures/popup.js';
26
+ export { listState } from './fixtures/list-state.js';
27
+ export type { ListStateFilter, ListStateOptions } from './fixtures/list-state.js';
28
+ export { consent, authorizeUrl, deleteClient, discoverAuthorizationServer, discoverProtectedResource, registerClient, signInAtAuthorizationServer, PKCE_CHALLENGE, PKCE_VERIFIER, } from './fixtures/consent.js';
29
+ export type { AuthorizationServerMetadata, ClientRegistration, ConsentOptions, ProtectedResourceMetadata, } from './fixtures/consent.js';
30
+ export { defineProtocolSuite } from './protocol/suite.js';
31
+ export { PROTOCOL_CASE_KEYS } from './protocol/types.js';
32
+ export type { ProtocolCases, ProtocolExpectations, ProtocolSuiteOptions } from './protocol/types.js';
33
+ export { protocolConfig } from './config.js';
34
+ export type { ProtocolConfigOptions } from './config.js';
package/dist/index.js ADDED
@@ -0,0 +1,25 @@
1
+ /**
2
+ * The shared Playwright layer-3 kit — HOW-TO §23.4, audit rule P-36.
3
+ *
4
+ * Six extensions of seven had no end-to-end suite at all, because §23.4 names
5
+ * six flows that are the same for every one of them and each would have had to
6
+ * write all six from scratch: a sign-in helper, a theme switcher, a way to find
7
+ * the shell, a way to drive the agent pop-up. This is those six, written once.
8
+ * An extension supplies a config file and its own six values.
9
+ *
10
+ * Read `README.md` for the values. `locators.ts` is the file a cortena-ui
11
+ * change breaks, on purpose: once, here, rather than in six suites.
12
+ */
13
+ export { readCredentials, hasCredentials, environmentName, MissingCredentialsError } from './env.js';
14
+ export { AGENT, CONSENT, CORTENA_UI, SESSION, SHELL, SIGN_IN, THEME } from './locators.js';
15
+ export { signIn, organisationStep, isSignInScreen, signInScreen, withToken } from './fixtures/sign-in.js';
16
+ export { embedded, postHostTheme, expectTokenStrippedFromUrl } from './fixtures/embedded.js';
17
+ export { theme, expectTheme, themeFromProjectName } from './fixtures/theme.js';
18
+ export { shell, expectShellChrome } from './fixtures/shell.js';
19
+ export { popup, expectSessionResumes } from './fixtures/popup.js';
20
+ export { listState } from './fixtures/list-state.js';
21
+ export { consent, authorizeUrl, deleteClient, discoverAuthorizationServer, discoverProtectedResource, registerClient, signInAtAuthorizationServer, PKCE_CHALLENGE, PKCE_VERIFIER, } from './fixtures/consent.js';
22
+ export { defineProtocolSuite } from './protocol/suite.js';
23
+ export { PROTOCOL_CASE_KEYS } from './protocol/types.js';
24
+ export { protocolConfig } from './config.js';
25
+ //# sourceMappingURL=index.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"index.js","sourceRoot":"","sources":["../src/index.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAEH,OAAO,EAAE,eAAe,EAAE,cAAc,EAAE,eAAe,EAAE,uBAAuB,EAAE,MAAM,UAAU,CAAC;AAGrG,OAAO,EAAE,KAAK,EAAE,OAAO,EAAE,UAAU,EAAE,OAAO,EAAE,KAAK,EAAE,OAAO,EAAE,KAAK,EAAE,MAAM,eAAe,CAAC;AAE3F,OAAO,EAAE,MAAM,EAAE,gBAAgB,EAAE,cAAc,EAAE,YAAY,EAAE,SAAS,EAAE,MAAM,uBAAuB,CAAC;AAG1G,OAAO,EAAE,QAAQ,EAAE,aAAa,EAAE,0BAA0B,EAAE,MAAM,wBAAwB,CAAC;AAG7F,OAAO,EAAE,KAAK,EAAE,WAAW,EAAE,oBAAoB,EAAE,MAAM,qBAAqB,CAAC;AAG/E,OAAO,EAAE,KAAK,EAAE,iBAAiB,EAAE,MAAM,qBAAqB,CAAC;AAG/D,OAAO,EAAE,KAAK,EAAE,oBAAoB,EAAE,MAAM,qBAAqB,CAAC;AAGlE,OAAO,EAAE,SAAS,EAAE,MAAM,0BAA0B,CAAC;AAGrD,OAAO,EACL,OAAO,EAAE,YAAY,EAAE,YAAY,EAAE,2BAA2B,EAAE,yBAAyB,EAC3F,cAAc,EAAE,2BAA2B,EAAE,cAAc,EAAE,aAAa,GAC3E,MAAM,uBAAuB,CAAC;AAK/B,OAAO,EAAE,mBAAmB,EAAE,MAAM,qBAAqB,CAAC;AAC1D,OAAO,EAAE,kBAAkB,EAAE,MAAM,qBAAqB,CAAC;AAGzD,OAAO,EAAE,cAAc,EAAE,MAAM,aAAa,CAAC"}