@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,220 @@
1
+ import { expect } from '@playwright/test';
2
+ import { CONSENT } from '../locators.js';
3
+ import { readCredentials } from '../env.js';
4
+ /**
5
+ * The MCP consent page (§23.4, rules P-08 and P-16).
6
+ *
7
+ * This is the one screen of the six that **no extension renders**. The OAuth
8
+ * flow lands on cortena-auth's own hosted page — a different deployment, a
9
+ * different repository, and today not built from cortena-ui at all. So it is
10
+ * located by what a consent page *is* rather than by a `data-slot` this
11
+ * repository could promise: a page that names the client and the resource,
12
+ * lists the scopes being asked for, and offers a way to allow and a way to
13
+ * refuse. `ConsentScreen`'s slots are tried first, so the day cortena-auth
14
+ * adopts the shared composition this stops being heuristic and nothing here
15
+ * changes.
16
+ *
17
+ * The whole request is **discovered**, not written down: RFC 9728
18
+ * `/.well-known/oauth-protected-resource` on the extension's own host gives the
19
+ * resource identifier, the authorisation server and the scopes; the server's
20
+ * metadata gives the endpoints. That is exactly the sequence an MCP client
21
+ * performs, so a deployment whose metadata is wrong fails here rather than
22
+ * passing against values a test file remembered.
23
+ *
24
+ * It is asserted **without accepting**. Pressing Allow mints a real grant for
25
+ * the test user against a real resource, and a suite that does that on every
26
+ * run leaves a trail of grants nobody revokes. What the protocol requires is
27
+ * that the page exists, says what is being asked for, and can be refused.
28
+ */
29
+ export async function consent(page, options) {
30
+ const resource = await discoverProtectedResource(page, options.host);
31
+ const issuer = resource.authorization_servers[0];
32
+ expect(issuer, `${options.host} advertises no authorization server (RFC 9728)`).toBeTruthy();
33
+ const server = await discoverAuthorizationServer(page, issuer);
34
+ const scopes = options.scopes ?? resource.scopes_supported?.slice(0, 1) ?? [];
35
+ const redirectUri = options.redirectUri ?? `${resource.resource.replace(/\/+$/, '')}/callback`;
36
+ /*
37
+ * A client registered here is a record on a real authorisation server, and
38
+ * this suite runs on every deploy in two theme projects. Left behind, that is
39
+ * two `dcr-…` clients per deploy for as long as nobody looks — the same
40
+ * litter the refusal below exists to avoid on the grant side. So the
41
+ * registration is remembered and deleted in the `finally`, and a client
42
+ * supplied by the caller is never touched.
43
+ */
44
+ const registration = options.clientId
45
+ ? undefined
46
+ : await registerClient(page, server, redirectUri);
47
+ const clientId = options.clientId ?? registration.clientId;
48
+ try {
49
+ await page.goto(authorizeUrl({ server, clientId, redirectUri, resource: resource.resource, scopes }));
50
+ // The authorisation request lands on the server's own sign-in first. That
51
+ // is correct — consent is a *user's* decision — so it is walked, not
52
+ // skipped.
53
+ await signInAtAuthorizationServer(page);
54
+ const shared = page.locator(CONSENT.screen);
55
+ if (await shared.isVisible().catch(() => false)) {
56
+ await expect(page.locator(CONSENT.scopes)).toBeVisible();
57
+ }
58
+ const allow = page.getByRole('button', { name: CONSENT.byForm.allow });
59
+ const deny = page.getByRole('button', { name: CONSENT.byForm.deny });
60
+ await expect(allow.first(), 'the consent page must offer a way to allow').toBeVisible();
61
+ await expect(deny.first(), 'a consent page with no way to refuse is not consent').toBeVisible();
62
+ // It has to say what is being asked for, or the user is consenting to a word.
63
+ for (const scope of scopes) {
64
+ await expect(page.getByText(scope, { exact: false }).first()).toBeVisible();
65
+ }
66
+ // Refused, never accepted. The flow ends here and no grant exists.
67
+ await deny.first().click();
68
+ }
69
+ finally {
70
+ if (registration)
71
+ await deleteClient(page, registration);
72
+ }
73
+ }
74
+ /** RFC 9728, from the extension's own host. */
75
+ export async function discoverProtectedResource(page, host) {
76
+ const url = `${host.replace(/\/+$/, '')}/.well-known/oauth-protected-resource`;
77
+ const res = await page.request.get(url);
78
+ expect(res.status(), `${url} must serve the protected-resource metadata (P-16)`).toBe(200);
79
+ return (await res.json());
80
+ }
81
+ export async function discoverAuthorizationServer(page, issuer) {
82
+ const url = `${issuer.replace(/\/+$/, '')}/.well-known/oauth-authorization-server`;
83
+ const res = await page.request.get(url);
84
+ expect(res.status(), `${url} must serve the authorization-server metadata`).toBe(200);
85
+ return (await res.json());
86
+ }
87
+ /**
88
+ * Dynamic client registration — RFC 7591, and what every MCP client that has
89
+ * never met this server does on first use. A suite that needs a client id
90
+ * provisioned by hand is a suite that stops working the day the environment is
91
+ * rebuilt, which is the day it is most needed.
92
+ */
93
+ export async function registerClient(page, server, redirectUri) {
94
+ expect(server.registration_endpoint, 'the authorization server offers no registration endpoint, so the suite needs a client id '
95
+ + 'in CORTENA_E2E_MCP_CLIENT_ID').toBeTruthy();
96
+ const res = await page.request.post(server.registration_endpoint, {
97
+ data: {
98
+ client_name: 'cortena-e2e',
99
+ redirect_uris: [redirectUri],
100
+ grant_types: ['authorization_code'],
101
+ response_types: ['code'],
102
+ token_endpoint_auth_method: 'none',
103
+ },
104
+ });
105
+ expect(res.status(), 'dynamic client registration failed').toBeLessThan(300);
106
+ const body = (await res.json());
107
+ expect(body.client_id, 'the registration response carried no client_id').toBeTruthy();
108
+ /*
109
+ * RFC 7591 §3.2.1: a server that supports management hands back the token
110
+ * and the URI for it right here, and this is the only moment they exist —
111
+ * the token is not re-issuable, so a registration whose response was thrown
112
+ * away is a client nobody can delete.
113
+ */
114
+ return {
115
+ clientId: body.client_id,
116
+ registrationAccessToken: body.registration_access_token,
117
+ registrationClientUri: body.registration_client_uri
118
+ ?? `${server.registration_endpoint.replace(/\/+$/, '')}/${encodeURIComponent(body.client_id)}`,
119
+ };
120
+ }
121
+ /**
122
+ * Deletes the client this run registered — RFC 7592.
123
+ *
124
+ * Best effort, and deliberately so. Client management is optional: a server
125
+ * that does not implement it answers 404, 405 or 501, and failing the consent
126
+ * spec because the *cleanup* was not supported would report the product broken
127
+ * over a housekeeping detail. It says what happened and moves on; what it must
128
+ * never do is silently skip cleanup on a server that does support it, which is
129
+ * why the outcome is always logged.
130
+ */
131
+ export async function deleteClient(page, registration) {
132
+ const { clientId, registrationAccessToken, registrationClientUri } = registration;
133
+ if (!registrationClientUri) {
134
+ process.stdout.write(`[e2e-kit] client ${clientId} was registered but the server gave no management URI, so it `
135
+ + 'cannot be deleted. It will accumulate one per run.\n');
136
+ return;
137
+ }
138
+ try {
139
+ const res = await page.request.delete(registrationClientUri, {
140
+ headers: registrationAccessToken
141
+ ? { authorization: `Bearer ${registrationAccessToken}` }
142
+ : {},
143
+ failOnStatusCode: false,
144
+ });
145
+ const status = res.status();
146
+ if (status === 204 || status === 200)
147
+ return;
148
+ process.stdout.write(`[e2e-kit] could not delete the registered client ${clientId}: ${status}. RFC 7592 client `
149
+ + 'management is optional, so this is a note rather than a failure.\n');
150
+ }
151
+ catch (err) {
152
+ process.stdout.write(`[e2e-kit] could not delete the registered client ${clientId}: `
153
+ + `${err instanceof Error ? err.message : String(err)}\n`);
154
+ }
155
+ }
156
+ /**
157
+ * The authorisation request, with PKCE.
158
+ *
159
+ * The verifier is fixed and public on purpose: no code is ever exchanged — the
160
+ * suite refuses the grant — so there is nothing for a secret to protect, and a
161
+ * generated one would only make the failure harder to reproduce.
162
+ */
163
+ export const PKCE_VERIFIER = 'dBjftJeZ4CVP-mB92K27uhbUJU1p1r_wW1gFWFOEjXk';
164
+ export const PKCE_CHALLENGE = 'E9Melhoa2OwvFrEMTJguCHaoeK1t8URWbuGJSstw-cM';
165
+ export function authorizeUrl(input) {
166
+ const url = new URL(input.server.authorization_endpoint);
167
+ url.searchParams.set('response_type', 'code');
168
+ url.searchParams.set('client_id', input.clientId);
169
+ url.searchParams.set('redirect_uri', input.redirectUri);
170
+ // RFC 8707. Without it the token is for the server rather than for this
171
+ // extension, which is the confused-deputy the MCP spec's audience rule closes.
172
+ url.searchParams.set('resource', input.resource);
173
+ if (input.scopes.length)
174
+ url.searchParams.set('scope', input.scopes.join(' '));
175
+ url.searchParams.set('state', 'cortena-e2e');
176
+ url.searchParams.set('code_challenge', PKCE_CHALLENGE);
177
+ url.searchParams.set('code_challenge_method', 'S256');
178
+ return url.toString();
179
+ }
180
+ /**
181
+ * cortena-auth's own sign-in, which the authorisation request lands on before
182
+ * it will show a consent page. Its markup is the server's, not cortena-ui's,
183
+ * so this is by accessible name and nothing else. Already signed in, there is
184
+ * no form and this returns.
185
+ */
186
+ export async function signInAtAuthorizationServer(page) {
187
+ const credentials = readCredentials();
188
+ // The organisation first. cortena-auth asks which org is signing in before
189
+ // it asks for a credential, for the same reason an extension's login does:
190
+ // the org decides whether this is a password, Google, or both.
191
+ const org = page.getByLabel(CONSENT.signIn.org);
192
+ if (await org.isVisible({ timeout: 15_000 }).catch(() => false)) {
193
+ await org.fill(credentials.orgId);
194
+ await page.getByRole('button', { name: CONSENT.signIn.orgSubmit }).first().click();
195
+ }
196
+ const password = page.getByLabel(CONSENT.signIn.password);
197
+ if (!(await password.isVisible({ timeout: 15_000 }).catch(() => false))) {
198
+ // Already signed in at the authorisation server: straight to consent.
199
+ await pickOrganisation(page, credentials.orgId);
200
+ return;
201
+ }
202
+ if (!credentials.email || !credentials.password) {
203
+ throw new Error('The consent page needs CORTENA_E2E_USER_EMAIL and CORTENA_E2E_USER_PASSWORD: consent is a '
204
+ + 'user\'s decision, so the authorisation server asks who is deciding, and a pre-minted '
205
+ + 'CORTENA_E2E_TOKEN is not a session on that host.');
206
+ }
207
+ await page.getByLabel(CONSENT.signIn.email).fill(credentials.email);
208
+ await password.fill(credentials.password);
209
+ await page.getByRole('button', { name: CONSENT.signIn.submit }).first().click();
210
+ await pickOrganisation(page, credentials.orgId);
211
+ }
212
+ /** The org step, for a user who is a member of more than one. */
213
+ async function pickOrganisation(page, orgId) {
214
+ const picker = page.locator(CONSENT.byForm.orgPick);
215
+ if (!(await picker.first().isVisible({ timeout: 3_000 }).catch(() => false)))
216
+ return;
217
+ const mine = picker.filter({ hasText: orgId });
218
+ await ((await mine.count()) > 0 ? mine.first() : picker.first()).click();
219
+ }
220
+ //# sourceMappingURL=consent.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"consent.js","sourceRoot":"","sources":["../../src/fixtures/consent.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,47 @@
1
+ import { type Page } from '@playwright/test';
2
+ export interface EmbeddedOptions {
3
+ /** The route inside the extension the tile opens. Defaults to `/`. */
4
+ route?: string;
5
+ /** Overrides `CORTENA_E2E_TOKEN`. */
6
+ token?: string;
7
+ /**
8
+ * The theme CortenaWeb posts in after the frame loads. Left unset, no message
9
+ * is posted and the extension keeps whatever it decided for itself.
10
+ */
11
+ hostTheme?: 'light' | 'dark';
12
+ }
13
+ /**
14
+ * Launches the extension the way CortenaWeb does.
15
+ *
16
+ * CortenaWeb puts the extension in an iframe and hands it an **access token on
17
+ * the URL and no refresh token**: `?token=<jwt>&refreshToken=none`. The host
18
+ * holds the real refresh token, and the placeholder is what tells the
19
+ * extension the session is not its to rotate — Tasks reads exactly this and
20
+ * mounts `SessionGuard` only when the session is its own, because a guard
21
+ * counting down to a refresh it cannot perform drops the user on a login
22
+ * screen inside the iframe at about 75% of the token's life (§10.4).
23
+ *
24
+ * So the check is not "does it render". It is: with a token and `none`, the
25
+ * app renders its shell and shows **no login screen** (P-04). An extension
26
+ * that quietly falls through to its own login page inside the frame is the
27
+ * failure this catches, and it is invisible to every other layer.
28
+ *
29
+ * The frame itself is not reproduced here — a cross-origin iframe would only
30
+ * add a frame handle to every locator, and CortenaWeb's contract with the
31
+ * extension is the URL and the theme message, both of which are driven
32
+ * directly.
33
+ */
34
+ export declare function embedded(page: Page, options?: EmbeddedOptions): Promise<void>;
35
+ /**
36
+ * The message CortenaWeb posts into the frame on load and on every theme
37
+ * change. `useCortenaTheme` listens for it and applies the *mode* — never the
38
+ * host's raw colours, which would override the design tokens.
39
+ */
40
+ export declare function postHostTheme(page: Page, mode: 'light' | 'dark'): Promise<void>;
41
+ /**
42
+ * The token is on the URL for exactly one navigation. The app replaces the
43
+ * history entry as soon as it has read it, so a screenshot, a trace and a
44
+ * shared link do not carry a live credential — and if it does not, this is
45
+ * where that leak is caught.
46
+ */
47
+ export declare function expectTokenStrippedFromUrl(page: Page): Promise<void>;
@@ -0,0 +1,60 @@
1
+ import { expect } from '@playwright/test';
2
+ import { SHELL, THEME } from '../locators.js';
3
+ import { isSignInScreen, withToken } from './sign-in.js';
4
+ import { readCredentials } from '../env.js';
5
+ /**
6
+ * Launches the extension the way CortenaWeb does.
7
+ *
8
+ * CortenaWeb puts the extension in an iframe and hands it an **access token on
9
+ * the URL and no refresh token**: `?token=<jwt>&refreshToken=none`. The host
10
+ * holds the real refresh token, and the placeholder is what tells the
11
+ * extension the session is not its to rotate — Tasks reads exactly this and
12
+ * mounts `SessionGuard` only when the session is its own, because a guard
13
+ * counting down to a refresh it cannot perform drops the user on a login
14
+ * screen inside the iframe at about 75% of the token's life (§10.4).
15
+ *
16
+ * So the check is not "does it render". It is: with a token and `none`, the
17
+ * app renders its shell and shows **no login screen** (P-04). An extension
18
+ * that quietly falls through to its own login page inside the frame is the
19
+ * failure this catches, and it is invisible to every other layer.
20
+ *
21
+ * The frame itself is not reproduced here — a cross-origin iframe would only
22
+ * add a frame handle to every locator, and CortenaWeb's contract with the
23
+ * extension is the URL and the theme message, both of which are driven
24
+ * directly.
25
+ */
26
+ export async function embedded(page, options = {}) {
27
+ const token = options.token ?? readCredentials().token;
28
+ if (!token) {
29
+ throw new Error('embedded() needs CORTENA_E2E_TOKEN: the launch it reproduces IS the token handoff, and '
30
+ + 'signing in through the form first would prove something else.');
31
+ }
32
+ await page.goto(withToken(options.route ?? '/', token, 'none'));
33
+ // The shell first: it is what proves the app rendered at all, and it is what
34
+ // makes the short timeout below an answer rather than a race.
35
+ await expect(page.locator(SHELL.root)).toBeVisible({ timeout: 30_000 });
36
+ expect(await isSignInScreen(page, { timeout: 1_000 }), 'launched with a token and refreshToken=none, the extension must not show a login screen (§7.3, P-04)').toBe(false);
37
+ if (options.hostTheme)
38
+ await postHostTheme(page, options.hostTheme);
39
+ }
40
+ /**
41
+ * The message CortenaWeb posts into the frame on load and on every theme
42
+ * change. `useCortenaTheme` listens for it and applies the *mode* — never the
43
+ * host's raw colours, which would override the design tokens.
44
+ */
45
+ export async function postHostTheme(page, mode) {
46
+ await page.evaluate((theme) => {
47
+ window.postMessage({ type: 'cortena-theme', theme }, '*');
48
+ }, mode);
49
+ await expect(page.locator('html')).toHaveAttribute(THEME.attribute, mode);
50
+ }
51
+ /**
52
+ * The token is on the URL for exactly one navigation. The app replaces the
53
+ * history entry as soon as it has read it, so a screenshot, a trace and a
54
+ * shared link do not carry a live credential — and if it does not, this is
55
+ * where that leak is caught.
56
+ */
57
+ export async function expectTokenStrippedFromUrl(page) {
58
+ await expect(page).not.toHaveURL(/[?&]token=/);
59
+ }
60
+ //# sourceMappingURL=embedded.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"embedded.js","sourceRoot":"","sources":["../../src/fixtures/embedded.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAa,MAAM,kBAAkB,CAAC;AACrD,OAAO,EAAE,KAAK,EAAE,KAAK,EAAE,MAAM,gBAAgB,CAAC;AAC9C,OAAO,EAAE,cAAc,EAAE,SAAS,EAAE,MAAM,cAAc,CAAC;AACzD,OAAO,EAAE,eAAe,EAAE,MAAM,WAAW,CAAC;AAc5C;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,MAAM,CAAC,KAAK,UAAU,QAAQ,CAAC,IAAU,EAAE,UAA2B,EAAE;IACtE,MAAM,KAAK,GAAG,OAAO,CAAC,KAAK,IAAI,eAAe,EAAE,CAAC,KAAK,CAAC;IACvD,IAAI,CAAC,KAAK,EAAE,CAAC;QACX,MAAM,IAAI,KAAK,CACb,yFAAyF;cACvF,+DAA+D,CAClE,CAAC;IACJ,CAAC;IAED,MAAM,IAAI,CAAC,IAAI,CAAC,SAAS,CAAC,OAAO,CAAC,KAAK,IAAI,GAAG,EAAE,KAAK,EAAE,MAAM,CAAC,CAAC,CAAC;IAEhE,6EAA6E;IAC7E,8DAA8D;IAC9D,MAAM,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC,CAAC,WAAW,CAAC,EAAE,OAAO,EAAE,MAAM,EAAE,CAAC,CAAC;IACxE,MAAM,CACJ,MAAM,cAAc,CAAC,IAAI,EAAE,EAAE,OAAO,EAAE,KAAK,EAAE,CAAC,EAC9C,sGAAsG,CACvG,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC;IAEd,IAAI,OAAO,CAAC,SAAS;QAAE,MAAM,aAAa,CAAC,IAAI,EAAE,OAAO,CAAC,SAAS,CAAC,CAAC;AACtE,CAAC;AAED;;;;GAIG;AACH,MAAM,CAAC,KAAK,UAAU,aAAa,CAAC,IAAU,EAAE,IAAsB;IACpE,MAAM,IAAI,CAAC,QAAQ,CAAC,CAAC,KAAK,EAAE,EAAE;QAC5B,MAAM,CAAC,WAAW,CAAC,EAAE,IAAI,EAAE,eAAe,EAAE,KAAK,EAAE,EAAE,GAAG,CAAC,CAAC;IAC5D,CAAC,EAAE,IAAI,CAAC,CAAC;IACT,MAAM,MAAM,CAAC,IAAI,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,eAAe,CAAC,KAAK,CAAC,SAAS,EAAE,IAAI,CAAC,CAAC;AAC5E,CAAC;AAED;;;;;GAKG;AACH,MAAM,CAAC,KAAK,UAAU,0BAA0B,CAAC,IAAU;IACzD,MAAM,MAAM,CAAC,IAAI,CAAC,CAAC,GAAG,CAAC,SAAS,CAAC,YAAY,CAAC,CAAC;AACjD,CAAC"}
@@ -0,0 +1,43 @@
1
+ import { type Page } from '@playwright/test';
2
+ export interface ListStateFilter {
3
+ /** The query parameter that holds the filter. URL-first is the whole rule (§9). */
4
+ param: string;
5
+ value: string;
6
+ }
7
+ export interface ListStateOptions {
8
+ /** The list route, without a query. */
9
+ route: string;
10
+ filter: ListStateFilter;
11
+ /**
12
+ * How to open a row, and how to know one is open. `open` is given the page
13
+ * and does whatever the extension's list does — a click, usually.
14
+ */
15
+ detail: {
16
+ open: (page: Page) => Promise<void>;
17
+ /** A URL the detail must be at. A detail that is not a route has no Back. */
18
+ urlPattern: RegExp;
19
+ close: (page: Page) => Promise<void>;
20
+ };
21
+ }
22
+ /**
23
+ * The five-way check of §9 / rule P-09.
24
+ *
25
+ * A list's view, filter, sort, page and selection belong in the URL. Not for
26
+ * neatness: state held in a component is state the user loses on every single
27
+ * interaction that leaves the screen, and the loss is invisible to every test
28
+ * that does not use a real browser with real history.
29
+ *
30
+ * So: set the filter, then do the five things that destroy component state and
31
+ * assert the filter survived all five.
32
+ *
33
+ * 1. open a detail (a route change)
34
+ * 2. close it (back to the list)
35
+ * 3. Back (the browser's, not the app's)
36
+ * 4. reload (no memory at all)
37
+ * 5. the URL in a new page (no memory and no history)
38
+ *
39
+ * Five separate mechanisms, and an implementation can pass four of them by
40
+ * accident. Copying the URL into a second page is the one that cannot be
41
+ * faked: if the filter is not in the URL, the new page cannot know it.
42
+ */
43
+ export declare function listState(page: Page, options: ListStateOptions): Promise<void>;
@@ -0,0 +1,58 @@
1
+ import { expect } from '@playwright/test';
2
+ /**
3
+ * The five-way check of §9 / rule P-09.
4
+ *
5
+ * A list's view, filter, sort, page and selection belong in the URL. Not for
6
+ * neatness: state held in a component is state the user loses on every single
7
+ * interaction that leaves the screen, and the loss is invisible to every test
8
+ * that does not use a real browser with real history.
9
+ *
10
+ * So: set the filter, then do the five things that destroy component state and
11
+ * assert the filter survived all five.
12
+ *
13
+ * 1. open a detail (a route change)
14
+ * 2. close it (back to the list)
15
+ * 3. Back (the browser's, not the app's)
16
+ * 4. reload (no memory at all)
17
+ * 5. the URL in a new page (no memory and no history)
18
+ *
19
+ * Five separate mechanisms, and an implementation can pass four of them by
20
+ * accident. Copying the URL into a second page is the one that cannot be
21
+ * faked: if the filter is not in the URL, the new page cannot know it.
22
+ */
23
+ export async function listState(page, options) {
24
+ const { route, filter, detail } = options;
25
+ const separator = route.includes('?') ? '&' : '?';
26
+ const filtered = `${route}${separator}${filter.param}=${encodeURIComponent(filter.value)}`;
27
+ const expected = new RegExp(`[?&]${escapeRegExp(filter.param)}=${escapeRegExp(filter.value)}`);
28
+ await page.goto(filtered);
29
+ await expect(page, 'the filter must be in the URL before anything else is asserted (§9)').toHaveURL(expected);
30
+ // 1 — into the detail.
31
+ await detail.open(page);
32
+ await expect(page).toHaveURL(detail.urlPattern);
33
+ // 2 — and back out. The list must return to the filter it was left on.
34
+ await detail.close(page);
35
+ await expect(page, 'closing the detail lost the filter').toHaveURL(expected);
36
+ // 3 — the browser's Back, which is the one an app's own "back" button hides.
37
+ await page.goBack();
38
+ await page.goForward();
39
+ await expect(page, 'Back and Forward lost the filter').toHaveURL(expected);
40
+ // 4 — a reload has no memory of anything but the URL.
41
+ await page.reload();
42
+ await expect(page, 'reload lost the filter').toHaveURL(expected);
43
+ // 5 — a second page has no memory and no history either. This is the check
44
+ // that cannot be passed by holding state anywhere but the URL.
45
+ const url = page.url();
46
+ const copy = await page.context().newPage();
47
+ try {
48
+ await copy.goto(url);
49
+ await expect(copy, 'the URL did not carry the filter into a new tab').toHaveURL(expected);
50
+ }
51
+ finally {
52
+ await copy.close();
53
+ }
54
+ }
55
+ function escapeRegExp(value) {
56
+ return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
57
+ }
58
+ //# sourceMappingURL=list-state.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"list-state.js","sourceRoot":"","sources":["../../src/fixtures/list-state.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,MAAM,EAAa,MAAM,kBAAkB,CAAC;AAwBrD;;;;;;;;;;;;;;;;;;;;GAoBG;AACH,MAAM,CAAC,KAAK,UAAU,SAAS,CAAC,IAAU,EAAE,OAAyB;IACnE,MAAM,EAAE,KAAK,EAAE,MAAM,EAAE,MAAM,EAAE,GAAG,OAAO,CAAC;IAC1C,MAAM,SAAS,GAAG,KAAK,CAAC,QAAQ,CAAC,GAAG,CAAC,CAAC,CAAC,CAAC,GAAG,CAAC,CAAC,CAAC,GAAG,CAAC;IAClD,MAAM,QAAQ,GAAG,GAAG,KAAK,GAAG,SAAS,GAAG,MAAM,CAAC,KAAK,IAAI,kBAAkB,CAAC,MAAM,CAAC,KAAK,CAAC,EAAE,CAAC;IAC3F,MAAM,QAAQ,GAAG,IAAI,MAAM,CAAC,OAAO,YAAY,CAAC,MAAM,CAAC,KAAK,CAAC,IAAI,YAAY,CAAC,MAAM,CAAC,KAAK,CAAC,EAAE,CAAC,CAAC;IAE/F,MAAM,IAAI,CAAC,IAAI,CAAC,QAAQ,CAAC,CAAC;IAC1B,MAAM,MAAM,CAAC,IAAI,EAAE,qEAAqE,CAAC,CAAC,SAAS,CAAC,QAAQ,CAAC,CAAC;IAE9G,uBAAuB;IACvB,MAAM,MAAM,CAAC,IAAI,CAAC,IAAI,CAAC,CAAC;IACxB,MAAM,MAAM,CAAC,IAAI,CAAC,CAAC,SAAS,CAAC,MAAM,CAAC,UAAU,CAAC,CAAC;IAEhD,uEAAuE;IACvE,MAAM,MAAM,CAAC,KAAK,CAAC,IAAI,CAAC,CAAC;IACzB,MAAM,MAAM,CAAC,IAAI,EAAE,oCAAoC,CAAC,CAAC,SAAS,CAAC,QAAQ,CAAC,CAAC;IAE7E,6EAA6E;IAC7E,MAAM,IAAI,CAAC,MAAM,EAAE,CAAC;IACpB,MAAM,IAAI,CAAC,SAAS,EAAE,CAAC;IACvB,MAAM,MAAM,CAAC,IAAI,EAAE,kCAAkC,CAAC,CAAC,SAAS,CAAC,QAAQ,CAAC,CAAC;IAE3E,sDAAsD;IACtD,MAAM,IAAI,CAAC,MAAM,EAAE,CAAC;IACpB,MAAM,MAAM,CAAC,IAAI,EAAE,wBAAwB,CAAC,CAAC,SAAS,CAAC,QAAQ,CAAC,CAAC;IAEjE,2EAA2E;IAC3E,+DAA+D;IAC/D,MAAM,GAAG,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;IACvB,MAAM,IAAI,GAAG,MAAM,IAAI,CAAC,OAAO,EAAE,CAAC,OAAO,EAAE,CAAC;IAC5C,IAAI,CAAC;QACH,MAAM,IAAI,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;QACrB,MAAM,MAAM,CAAC,IAAI,EAAE,iDAAiD,CAAC,CAAC,SAAS,CAAC,QAAQ,CAAC,CAAC;IAC5F,CAAC;YAAS,CAAC;QACT,MAAM,IAAI,CAAC,KAAK,EAAE,CAAC;IACrB,CAAC;AACH,CAAC;AAED,SAAS,YAAY,CAAC,KAAa;IACjC,OAAO,KAAK,CAAC,OAAO,CAAC,qBAAqB,EAAE,MAAM,CAAC,CAAC;AACtD,CAAC"}
@@ -0,0 +1,44 @@
1
+ import { type Locator, type Page } from '@playwright/test';
2
+ export interface Popup {
3
+ pill: Locator;
4
+ panel: Locator;
5
+ input: Locator;
6
+ send: Locator;
7
+ stop: Locator;
8
+ turns: Locator;
9
+ userMessages: Locator;
10
+ assistantMessages: Locator;
11
+ a2ui: Locator;
12
+ toolCards: Locator;
13
+ open: () => Promise<void>;
14
+ close: () => Promise<void>;
15
+ /** Types and sends. Does not wait for the reply. */
16
+ send_: (text: string) => Promise<void>;
17
+ /** Sends and waits for the turn to finish streaming. */
18
+ ask: (text: string, options?: {
19
+ timeout?: number;
20
+ }) => Promise<void>;
21
+ /** Waits for a run to start and then finish. */
22
+ awaitStream: (options?: {
23
+ timeout?: number;
24
+ }) => Promise<void>;
25
+ }
26
+ /**
27
+ * `AgentChatPopup` — the extension's agent, in the corner of every screen
28
+ * (§19, rule P-25).
29
+ *
30
+ * Four things have to be true and only a browser can see any of them: it
31
+ * opens, a turn **streams** rather than arriving in one piece, the session
32
+ * **survives a reload**, and a tool that answers with a block of A2UI draws it
33
+ * rather than printing JSON. The third is the one extensions get wrong: a chat
34
+ * held in component state looks perfect until the user refreshes, and then the
35
+ * conversation they were having is gone.
36
+ */
37
+ export declare function popup(page: Page): Popup;
38
+ /**
39
+ * The reload half of P-25: the conversation is a session on the server, not a
40
+ * list in a React state hook. After a reload the pop-up is closed again — that
41
+ * is the shell's correct behaviour — so it is reopened and the turn that was
42
+ * sent before the reload has to still be there.
43
+ */
44
+ export declare function expectSessionResumes(page: Page, sent: string): Promise<void>;
@@ -0,0 +1,81 @@
1
+ import { expect } from '@playwright/test';
2
+ import { AGENT } from '../locators.js';
3
+ /**
4
+ * `AgentChatPopup` — the extension's agent, in the corner of every screen
5
+ * (§19, rule P-25).
6
+ *
7
+ * Four things have to be true and only a browser can see any of them: it
8
+ * opens, a turn **streams** rather than arriving in one piece, the session
9
+ * **survives a reload**, and a tool that answers with a block of A2UI draws it
10
+ * rather than printing JSON. The third is the one extensions get wrong: a chat
11
+ * held in component state looks perfect until the user refreshes, and then the
12
+ * conversation they were having is gone.
13
+ */
14
+ export function popup(page) {
15
+ const pill = page.locator(AGENT.pill);
16
+ const panel = page.locator(AGENT.panel);
17
+ const input = page.locator(AGENT.input);
18
+ const send = page.locator(AGENT.send);
19
+ const stop = page.locator(AGENT.stop);
20
+ const streaming = page.locator(AGENT.streaming);
21
+ const sendText = async (text) => {
22
+ await input.fill(text);
23
+ await send.click();
24
+ };
25
+ const awaitStream = async (options = {}) => {
26
+ const timeout = options.timeout ?? 90_000;
27
+ /*
28
+ * The stop button is the run. It is rendered while a run is in flight and
29
+ * replaced by send when it ends, so "the turn streamed" is: stop appeared,
30
+ * then stop went away. Waiting on message text instead would pass for a
31
+ * reply that arrived whole, which is the thing being ruled out.
32
+ */
33
+ await expect(stop.or(streaming).first()).toBeVisible({ timeout: 20_000 });
34
+ await expect(stop).toHaveCount(0, { timeout });
35
+ await expect(streaming).toHaveCount(0, { timeout });
36
+ };
37
+ return {
38
+ pill,
39
+ panel,
40
+ input,
41
+ send,
42
+ stop,
43
+ turns: page.locator(AGENT.turn),
44
+ userMessages: page.locator(AGENT.userMessage),
45
+ assistantMessages: page.locator(AGENT.assistantMessage),
46
+ a2ui: page.locator(AGENT.a2ui),
47
+ toolCards: page.locator(AGENT.toolCard),
48
+ async open() {
49
+ await expect(pill).toBeVisible();
50
+ await pill.click();
51
+ await expect(panel).toBeVisible();
52
+ await expect(input).toBeVisible();
53
+ },
54
+ async close() {
55
+ await page.locator(AGENT.collapse).click();
56
+ await expect(panel).toHaveCount(0);
57
+ },
58
+ send_: sendText,
59
+ async ask(text, options = {}) {
60
+ await sendText(text);
61
+ await expect(page.locator(AGENT.userMessage).filter({ hasText: text })).toBeVisible();
62
+ await awaitStream(options);
63
+ },
64
+ awaitStream,
65
+ };
66
+ }
67
+ /**
68
+ * The reload half of P-25: the conversation is a session on the server, not a
69
+ * list in a React state hook. After a reload the pop-up is closed again — that
70
+ * is the shell's correct behaviour — so it is reopened and the turn that was
71
+ * sent before the reload has to still be there.
72
+ */
73
+ export async function expectSessionResumes(page, sent) {
74
+ await page.reload();
75
+ const resumed = popup(page);
76
+ await resumed.open();
77
+ await expect(resumed.userMessages.filter({ hasText: sent }).first()).toBeVisible({
78
+ timeout: 30_000,
79
+ });
80
+ }
81
+ //# sourceMappingURL=popup.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"popup.js","sourceRoot":"","sources":["../../src/fixtures/popup.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,63 @@
1
+ import { type Locator, type Page } from '@playwright/test';
2
+ /**
3
+ * What the extension claims its shell says. The kit knows the *shape* of the
4
+ * chrome; only the extension knows its own name and its own destinations.
5
+ */
6
+ export interface ShellExpectations {
7
+ /** The words beside the mark, top-left. */
8
+ brandName: string;
9
+ /** Nav item labels that must be present. A subset: an extension may have more. */
10
+ navItems: string[];
11
+ /** Ids of the extension's own avatar-menu rows, beyond profile/settings/logout. */
12
+ menuItems?: string[];
13
+ }
14
+ /**
15
+ * Every part of `AppShell`, as locators.
16
+ *
17
+ * Written as a bundle rather than six helper functions because the assertions
18
+ * a spec makes about the shell are almost always several at once, and a spec
19
+ * that reaches for `page.locator('[data-slot="…"]')` itself is a spec that
20
+ * survives a rename the kit was supposed to catch.
21
+ */
22
+ export interface Shell {
23
+ root: Locator;
24
+ header: Locator;
25
+ brand: Locator;
26
+ brandName: Locator;
27
+ brandSquare: Locator;
28
+ brandMark: Locator;
29
+ nav: Locator;
30
+ navItems: Locator;
31
+ navItem: (label: string) => Locator;
32
+ navItemById: (id: string) => Locator;
33
+ /** The row marked `aria-current="page"`. */
34
+ currentNavItem: Locator;
35
+ navToggle: Locator;
36
+ navDrawer: Locator;
37
+ navDrawerClose: Locator;
38
+ main: Locator;
39
+ avatarTrigger: Locator;
40
+ menuItem: (id: string) => Locator;
41
+ cluster: Locator;
42
+ themeToggle: Locator;
43
+ helpButton: Locator;
44
+ helpPanel: Locator;
45
+ /** Opens the avatar menu and waits for it. */
46
+ openAvatarMenu: () => Promise<void>;
47
+ /** Opens the help panel and waits for the shell's aside. */
48
+ openHelp: () => Promise<void>;
49
+ /** Narrows the viewport until the drawer is the navigation, then opens it. */
50
+ openNavDrawer: () => Promise<void>;
51
+ }
52
+ export declare function shell(page: Page): Shell;
53
+ /**
54
+ * §10 / P-10, in one call: the mark and the name top-left, the destinations in
55
+ * the sidebar, **Settings under the avatar and nowhere else**, and the theme
56
+ * and help controls in the bottom-right cluster.
57
+ *
58
+ * The "and nowhere else" half is the one that decays. An extension adds a
59
+ * settings link to its sidebar because it is convenient, and the fleet stops
60
+ * having one place settings lives; asserting the absence is the only way that
61
+ * gets noticed.
62
+ */
63
+ export declare function expectShellChrome(page: Page, expectations: ShellExpectations): Promise<void>;