@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.
- package/LICENSE +7 -0
- package/README.md +126 -0
- package/dist/config.d.ts +59 -0
- package/dist/config.js +63 -0
- package/dist/config.js.map +1 -0
- package/dist/env.d.ts +43 -0
- package/dist/env.js +67 -0
- package/dist/env.js.map +1 -0
- package/dist/fixtures/consent.d.ts +107 -0
- package/dist/fixtures/consent.js +220 -0
- package/dist/fixtures/consent.js.map +1 -0
- package/dist/fixtures/embedded.d.ts +47 -0
- package/dist/fixtures/embedded.js +60 -0
- package/dist/fixtures/embedded.js.map +1 -0
- package/dist/fixtures/list-state.d.ts +43 -0
- package/dist/fixtures/list-state.js +58 -0
- package/dist/fixtures/list-state.js.map +1 -0
- package/dist/fixtures/popup.d.ts +44 -0
- package/dist/fixtures/popup.js +81 -0
- package/dist/fixtures/popup.js.map +1 -0
- package/dist/fixtures/shell.d.ts +63 -0
- package/dist/fixtures/shell.js +96 -0
- package/dist/fixtures/shell.js.map +1 -0
- package/dist/fixtures/sign-in.d.ts +59 -0
- package/dist/fixtures/sign-in.js +115 -0
- package/dist/fixtures/sign-in.js.map +1 -0
- package/dist/fixtures/theme.d.ts +42 -0
- package/dist/fixtures/theme.js +79 -0
- package/dist/fixtures/theme.js.map +1 -0
- package/dist/index.d.ts +34 -0
- package/dist/index.js +25 -0
- package/dist/index.js.map +1 -0
- package/dist/locators.d.ts +210 -0
- package/dist/locators.js +212 -0
- package/dist/locators.js.map +1 -0
- package/dist/protocol/suite.d.ts +31 -0
- package/dist/protocol/suite.js +196 -0
- package/dist/protocol/suite.js.map +1 -0
- package/dist/protocol/types.d.ts +61 -0
- package/dist/protocol/types.js +4 -0
- package/dist/protocol/types.js.map +1 -0
- 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>;
|