@progen-labs/spectra 0.4.1 → 0.6.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/README.md +76 -8
- package/agents/spectra.md +127 -0
- package/bin/spectra.js +19 -4
- package/dist/assets/index-mtYxDBW7.js +11 -0
- package/dist/assets/index-z14kjW8r.css +1 -0
- package/dist/index.html +2 -2
- package/package.json +5 -3
- package/schema/config.schema.json +74 -0
- package/server/agents-cli.js +66 -0
- package/server/agents.js +283 -0
- package/server/analysis/findings.js +18 -9
- package/server/cli.js +10 -4
- package/server/explore/evaluate-cli.js +14 -4
- package/server/explore/evaluate.js +3 -1
- package/server/pdf/template.js +2 -2
- package/server/postinstall.js +20 -0
- package/server/probe.browser.js +85 -14
- package/server/project-config.js +17 -5
- package/server/recorder.js +154 -15
- package/server/signin.js +524 -2
- package/server/test-agents.js +105 -0
- package/server/test-signin.js +179 -0
- package/shared/compare.js +9 -9
- package/shared/snapshot.js +1 -1
- package/src/App.jsx +1 -1
- package/src/components/CompareResults.jsx +1 -1
- package/src/components/ControlBar.jsx +83 -60
- package/src/components/EvaluationPanel.jsx +1 -1
- package/src/components/FilePanel.jsx +2 -2
- package/src/components/PageMap.jsx +26 -22
- package/src/components/ReactPanel.jsx +2 -4
- package/src/components/SignIn.jsx +291 -0
- package/src/components/Vitals.jsx +1 -1
- package/src/framework.js +12 -0
- package/src/heat.js +3 -3
- package/src/styles.css +91 -0
- package/dist/assets/index-DY8VmG9y.js +0 -11
- package/dist/assets/index-Dx1YSFIx.css +0 -1
package/server/signin.js
CHANGED
|
@@ -1,9 +1,45 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Signing in before profiling. Most production frontends sit behind a login,
|
|
3
|
+
* and Spectra's Chrome starts signed out. Every kind of web login is covered:
|
|
4
|
+
*
|
|
5
|
+
* form a username and password typed into the app's own login form: one
|
|
6
|
+
* step or two (username, Next, password), in the page, an iframe or
|
|
7
|
+
* a web component, behind a "Sign in" button, on the app or on an
|
|
8
|
+
* identity provider it redirects to (OAuth, OpenID Connect, SAML),
|
|
9
|
+
* optionally with an authenticator-app code (TOTP)
|
|
10
|
+
* link a sign-in URL template, e.g. a dev-only `/login/?redirect={url}`
|
|
11
|
+
* http the browser's own login prompt (HTTP Basic or Digest)
|
|
12
|
+
* token a header (only on the app's own origin), cookies, or localStorage
|
|
13
|
+
* entries set before the app loads
|
|
14
|
+
* manual you sign in yourself in Spectra's Chrome window (SMS or email
|
|
15
|
+
* codes, push approvals, passkeys, CAPTCHAs) and Spectra waits
|
|
16
|
+
*
|
|
17
|
+
* Secrets (the password, the TOTP secret, header values, cookies, storage
|
|
18
|
+
* entries) come from the dashboard or the environment, never from a file. They
|
|
19
|
+
* stay in memory: they are never written to disk, a report, a log or an error
|
|
20
|
+
* message, and signing in happens before recording starts, so it never appears
|
|
21
|
+
* in a trace. Spectra does not solve CAPTCHAs: that is what `manual` is for.
|
|
22
|
+
*/
|
|
23
|
+
import crypto from 'node:crypto';
|
|
24
|
+
|
|
25
|
+
export const METHODS = ['form', 'link', 'http', 'token', 'manual'];
|
|
26
|
+
export const ENV = {
|
|
27
|
+
user: 'SPECTRA_LOGIN_USER',
|
|
28
|
+
password: 'SPECTRA_LOGIN_PASSWORD',
|
|
29
|
+
totp: 'SPECTRA_LOGIN_TOTP_SECRET',
|
|
30
|
+
header: 'SPECTRA_LOGIN_HEADER', // "Name: value"
|
|
31
|
+
cookies: 'SPECTRA_LOGIN_COOKIES', // "name=value; name2=value2"
|
|
32
|
+
storage: 'SPECTRA_LOGIN_STORAGE', // "key=value; key2=value2" (localStorage)
|
|
33
|
+
};
|
|
34
|
+
export const USER_ENV = ENV.user;
|
|
35
|
+
export const PASSWORD_ENV = ENV.password;
|
|
36
|
+
|
|
1
37
|
/**
|
|
2
38
|
* Builds the URL that signs in and then lands on `pageUrl`.
|
|
3
39
|
*
|
|
4
40
|
* `template` is the app's login URL with a `{url}` placeholder for the page to
|
|
5
41
|
* return to, e.g. `http://localhost:3000/login/?redirect={url}`.
|
|
6
|
-
`{url}` becomes the URL-encoded path + query of the page (relative, which is
|
|
42
|
+
* `{url}` becomes the URL-encoded path + query of the page (relative, which is
|
|
7
43
|
* what login redirects usually accept). The template must not contain secrets:
|
|
8
44
|
* it is remembered in the browser's localStorage.
|
|
9
45
|
*/
|
|
@@ -20,8 +56,494 @@ export function buildSignInUrl(template, pageUrl) {
|
|
|
20
56
|
export function looksSignedOut(navigation, finalUrl) {
|
|
21
57
|
if (navigation && navigation.status >= 400) return true;
|
|
22
58
|
try {
|
|
23
|
-
return /^\/(?:login|auth)\b/i.test(new URL(finalUrl).pathname);
|
|
59
|
+
return /^\/(?:login|log-in|signin|sign-in|auth)\b/i.test(new URL(finalUrl).pathname);
|
|
24
60
|
} catch {
|
|
25
61
|
return false;
|
|
26
62
|
}
|
|
27
63
|
}
|
|
64
|
+
|
|
65
|
+
const text = v => (typeof v === 'string' ? v.trim() : '');
|
|
66
|
+
const secret = v => (typeof v === 'string' ? v : ''); // used exactly as given: spaces can matter
|
|
67
|
+
|
|
68
|
+
/**
|
|
69
|
+
* A sign-in in one shape. A string is a sign-in URL template (the original
|
|
70
|
+
* form); an object names its `method`, or the method is inferred from what it
|
|
71
|
+
* holds. Null when there is nothing to sign in with.
|
|
72
|
+
*/
|
|
73
|
+
export function normalizeSignIn(value) {
|
|
74
|
+
if (!value) return null;
|
|
75
|
+
if (typeof value === 'string') return text(value) ? { method: 'link', url: text(value) } : null;
|
|
76
|
+
const s = {
|
|
77
|
+
method: METHODS.includes(value.method) ? value.method : '',
|
|
78
|
+
url: text(value.url),
|
|
79
|
+
username: text(value.username),
|
|
80
|
+
password: secret(value.password),
|
|
81
|
+
totpSecret: text(value.totpSecret).replace(/\s+/g, ''),
|
|
82
|
+
startSelector: text(value.startSelector),
|
|
83
|
+
usernameSelector: text(value.usernameSelector),
|
|
84
|
+
passwordSelector: text(value.passwordSelector),
|
|
85
|
+
submitSelector: text(value.submitSelector),
|
|
86
|
+
codeSelector: text(value.codeSelector),
|
|
87
|
+
headerName: text(value.headerName),
|
|
88
|
+
headerValue: secret(value.headerValue).trim(),
|
|
89
|
+
cookies: secret(value.cookies).trim(),
|
|
90
|
+
storage: secret(value.storage).trim(),
|
|
91
|
+
waitMs: Number(value.waitMs) > 0 ? Number(value.waitMs) : 5 * 60_000,
|
|
92
|
+
};
|
|
93
|
+
if (!s.method)
|
|
94
|
+
s.method = s.username || s.password ? 'form' : s.headerValue || s.cookies || s.storage ? 'token' : s.url ? 'link' : '';
|
|
95
|
+
if (!s.method) return null;
|
|
96
|
+
if (s.method === 'link' && !s.url) return null;
|
|
97
|
+
if (s.method === 'form' && !s.username && !s.password) return null;
|
|
98
|
+
if (s.method === 'http' && !s.username) return null;
|
|
99
|
+
if (s.method === 'token' && !(s.headerValue || s.cookies || s.storage)) return null;
|
|
100
|
+
return s;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** True when the sign-in fills in a form rather than only visiting a URL. */
|
|
104
|
+
export const hasCredentials = s => Boolean(s && s.method === 'form' && (s.username || s.password));
|
|
105
|
+
|
|
106
|
+
/** "Name: value" as [name, value]. */
|
|
107
|
+
export const parseHeader = line => {
|
|
108
|
+
const i = (line || '').indexOf(':');
|
|
109
|
+
return i > 0 ? [line.slice(0, i).trim(), line.slice(i + 1).trim()] : null;
|
|
110
|
+
};
|
|
111
|
+
/** "a=1; b=2" as [['a', '1'], ['b', '2']]. */
|
|
112
|
+
export const parsePairs = list =>
|
|
113
|
+
(list || '')
|
|
114
|
+
.split(/;\s*|\n/)
|
|
115
|
+
.map(p => p.trim())
|
|
116
|
+
.filter(Boolean)
|
|
117
|
+
.map(p => {
|
|
118
|
+
const i = p.indexOf('=');
|
|
119
|
+
return i > 0 ? [p.slice(0, i).trim(), p.slice(i + 1).trim()] : null;
|
|
120
|
+
})
|
|
121
|
+
.filter(Boolean);
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The sign-in for a CLI run: command-line options first, then the project's
|
|
125
|
+
* spectra.config.json `signIn`, then the environment. Secrets only ever come
|
|
126
|
+
* from the environment, so they never sit in a file or in the process list.
|
|
127
|
+
*/
|
|
128
|
+
export function resolveSignIn(flags = {}, config = {}, env = process.env) {
|
|
129
|
+
const header = parseHeader(env[ENV.header]);
|
|
130
|
+
return normalizeSignIn({
|
|
131
|
+
method: flags.method || config.method,
|
|
132
|
+
url: flags.loginUrl || flags.signIn || config.url,
|
|
133
|
+
username: flags.loginUser || config.username || env[ENV.user],
|
|
134
|
+
password: env[ENV.password] || '',
|
|
135
|
+
totpSecret: env[ENV.totp] || '',
|
|
136
|
+
startSelector: flags.startSelector || config.startSelector,
|
|
137
|
+
usernameSelector: flags.usernameSelector || config.usernameSelector,
|
|
138
|
+
passwordSelector: flags.passwordSelector || config.passwordSelector,
|
|
139
|
+
submitSelector: flags.submitSelector || config.submitSelector,
|
|
140
|
+
codeSelector: flags.codeSelector || config.codeSelector,
|
|
141
|
+
headerName: header?.[0] || '',
|
|
142
|
+
headerValue: header?.[1] || '',
|
|
143
|
+
cookies: env[ENV.cookies] || '',
|
|
144
|
+
storage: env[ENV.storage] || '',
|
|
145
|
+
waitMs: (Number(flags.waitSeconds || config.waitSeconds) || 0) * 1000,
|
|
146
|
+
});
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The sign-in options every command shares, read with args.js's `opt`. The
|
|
151
|
+
* secrets are not among them: they only come from the environment.
|
|
152
|
+
*/
|
|
153
|
+
export const signInFromOptions = (opt, config = {}, env = process.env) =>
|
|
154
|
+
resolveSignIn(
|
|
155
|
+
{
|
|
156
|
+
method: opt('login'),
|
|
157
|
+
loginUrl: opt('login-url'),
|
|
158
|
+
signIn: opt('sign-in'),
|
|
159
|
+
loginUser: opt('login-user'),
|
|
160
|
+
startSelector: opt('login-start'),
|
|
161
|
+
usernameSelector: opt('login-user-field'),
|
|
162
|
+
passwordSelector: opt('login-password-field'),
|
|
163
|
+
submitSelector: opt('login-submit'),
|
|
164
|
+
codeSelector: opt('login-code-field'),
|
|
165
|
+
waitSeconds: opt('login-wait'),
|
|
166
|
+
},
|
|
167
|
+
config || {},
|
|
168
|
+
env,
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
/** Progress for the command line: what a session is doing to sign in. */
|
|
172
|
+
export function logSignIn(session, log = console.error) {
|
|
173
|
+
session.on('state', s => {
|
|
174
|
+
if (s.state !== 'signing-in') return;
|
|
175
|
+
log(
|
|
176
|
+
s.method === 'manual'
|
|
177
|
+
? `spectra: sign in in the Chrome window (any way the app asks); Spectra carries on once you are in …`
|
|
178
|
+
: 'spectra: signing in through the login form …',
|
|
179
|
+
);
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
/** What `spectra … --help` says about signing in. */
|
|
184
|
+
export const SIGN_IN_HELP = `Signing in (when the app is behind a login):
|
|
185
|
+
--login <method> form, link, http, token or manual (guessed when left out)
|
|
186
|
+
--login-url <url> The login page, e.g. https://app.example.com/login
|
|
187
|
+
({url} in it becomes the page to return to)
|
|
188
|
+
--login-user <name> The username or email (or ${ENV.user})
|
|
189
|
+
--login-wait <s> How long to wait for a manual sign-in (default 300)
|
|
190
|
+
--login-start, --login-user-field, --login-password-field, --login-submit,
|
|
191
|
+
--login-code-field <css> Point at the controls when Spectra can't find them
|
|
192
|
+
Secrets only come from the environment, never from a flag or a file:
|
|
193
|
+
${[
|
|
194
|
+
[ENV.password, 'the password (form, http)'],
|
|
195
|
+
[ENV.totp, 'the authenticator-app secret, for one-time codes'],
|
|
196
|
+
[ENV.header, '"Name: value", sent only to the app\'s own origin'],
|
|
197
|
+
[ENV.cookies, '"name=value; name2=value2"'],
|
|
198
|
+
[ENV.storage, 'localStorage entries, "key=value; key2=value2"'],
|
|
199
|
+
]
|
|
200
|
+
.map(([name, what]) => ` ${name.padEnd(27)}${what}`)
|
|
201
|
+
.join('\n')}`;
|
|
202
|
+
|
|
203
|
+
const CONFIG_KEYS = ['method', 'url', 'username', 'startSelector', 'usernameSelector', 'passwordSelector', 'submitSelector', 'codeSelector', 'waitSeconds'];
|
|
204
|
+
const SECRET_KEYS = { password: ENV.password, totpSecret: ENV.totp, headerValue: ENV.header, header: ENV.header, cookies: ENV.cookies, storage: ENV.storage };
|
|
205
|
+
|
|
206
|
+
/** Checks spectra.config.json's `signIn`; returns it, or throws a readable Error. */
|
|
207
|
+
export function validateSignInConfig(raw) {
|
|
208
|
+
if (raw == null) return null;
|
|
209
|
+
if (typeof raw !== 'object' || Array.isArray(raw))
|
|
210
|
+
throw new Error('"signIn" must be an object, e.g. { "method": "form", "url": "https://app.example.com/login", "username": "me@example.com" }.');
|
|
211
|
+
for (const [k, envName] of Object.entries(SECRET_KEYS))
|
|
212
|
+
if (k in raw) throw new Error(`"signIn" must not hold "${k}": set the ${envName} environment variable instead, so the secret never sits in a file.`);
|
|
213
|
+
const unknown = Object.keys(raw).filter(k => !CONFIG_KEYS.includes(k));
|
|
214
|
+
if (unknown.length) throw new Error(`"signIn" has unknown keys: ${unknown.join(', ')} (allowed: ${CONFIG_KEYS.join(', ')}).`);
|
|
215
|
+
if (raw.method != null && !METHODS.includes(raw.method)) throw new Error(`"signIn.method" must be one of ${METHODS.join(', ')}.`);
|
|
216
|
+
for (const k of CONFIG_KEYS.filter(k => k !== 'waitSeconds'))
|
|
217
|
+
if (raw[k] != null && typeof raw[k] !== 'string') throw new Error(`"signIn.${k}" must be a string.`);
|
|
218
|
+
if (raw.waitSeconds != null && !(Number(raw.waitSeconds) > 0)) throw new Error('"signIn.waitSeconds" must be a positive number.');
|
|
219
|
+
return raw;
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
// ---------------------------------------------------------------- TOTP
|
|
223
|
+
|
|
224
|
+
function base32(value) {
|
|
225
|
+
const alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ234567';
|
|
226
|
+
let bits = '';
|
|
227
|
+
for (const ch of value.toUpperCase().replace(/=+$/, '')) {
|
|
228
|
+
const v = alphabet.indexOf(ch);
|
|
229
|
+
if (v < 0) throw new Error('The authenticator secret is not valid base32 (letters A–Z and digits 2–7).');
|
|
230
|
+
bits += v.toString(2).padStart(5, '0');
|
|
231
|
+
}
|
|
232
|
+
const bytes = [];
|
|
233
|
+
for (let i = 0; i + 8 <= bits.length; i += 8) bytes.push(parseInt(bits.slice(i, i + 8), 2));
|
|
234
|
+
return Buffer.from(bytes);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** The current authenticator-app code (RFC 6238: HMAC-SHA1, 30 s, 6 digits) for a base32 secret. */
|
|
238
|
+
export function totp(secretBase32, now = Date.now(), { step = 30, digits = 6 } = {}) {
|
|
239
|
+
const counter = Buffer.alloc(8);
|
|
240
|
+
counter.writeBigUInt64BE(BigInt(Math.floor(now / 1000 / step)));
|
|
241
|
+
const h = crypto.createHmac('sha1', base32(secretBase32.replace(/\s+/g, ''))).update(counter).digest();
|
|
242
|
+
const o = h[h.length - 1] & 0xf;
|
|
243
|
+
return String((h.readUInt32BE(o) & 0x7fffffff) % 10 ** digits).padStart(digits, '0');
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
// ---------------------------------------------------------------- in the page
|
|
247
|
+
|
|
248
|
+
const sleep = ms => new Promise(r => setTimeout(r, ms));
|
|
249
|
+
|
|
250
|
+
// Runs in the page: every element, including those inside open shadow roots
|
|
251
|
+
// (web components), and whether an element can be seen and used.
|
|
252
|
+
const HELPERS = `
|
|
253
|
+
const deep = root => {
|
|
254
|
+
const out = [];
|
|
255
|
+
const walk = node => {
|
|
256
|
+
for (const el of node.querySelectorAll('*')) {
|
|
257
|
+
out.push(el);
|
|
258
|
+
if (el.shadowRoot) walk(el.shadowRoot);
|
|
259
|
+
}
|
|
260
|
+
};
|
|
261
|
+
walk(root);
|
|
262
|
+
return out;
|
|
263
|
+
};
|
|
264
|
+
const visible = el => {
|
|
265
|
+
if (!el || el.disabled) return false;
|
|
266
|
+
const r = el.getBoundingClientRect();
|
|
267
|
+
const s = getComputedStyle(el);
|
|
268
|
+
return r.width > 0 && r.height > 0 && s.visibility !== 'hidden' && s.display !== 'none' && s.opacity !== '0';
|
|
269
|
+
};
|
|
270
|
+
const describe = i => [i.name, i.id, i.placeholder, i.getAttribute('aria-label'), i.autocomplete, i.labels && [...i.labels].map(l => l.textContent).join(' ')].join(' ').toLowerCase();
|
|
271
|
+
const label = el => (el.textContent || el.value || el.getAttribute('aria-label') || '').trim();
|
|
272
|
+
const CODE = /one-?time|\\botp\\b|verification|2fa|\\bmfa\\b|totp|authenticator|security code|passcode/;
|
|
273
|
+
`;
|
|
274
|
+
|
|
275
|
+
// Every frame of the page: the login form may sit in an iframe.
|
|
276
|
+
const framesOf = page => [page.mainFrame(), ...page.frames().filter(f => f !== page.mainFrame())];
|
|
277
|
+
|
|
278
|
+
/** True when the page (any frame) shows a password field: a login screen, whatever its URL. */
|
|
279
|
+
export async function hasLoginForm(page) {
|
|
280
|
+
for (const frame of framesOf(page)) {
|
|
281
|
+
try {
|
|
282
|
+
if (await frame.evaluate(`(() => { ${HELPERS} return deep(document).some(el => el.tagName === 'INPUT' && el.type === 'password' && visible(el)); })()`))
|
|
283
|
+
return true;
|
|
284
|
+
} catch {}
|
|
285
|
+
}
|
|
286
|
+
return false;
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
// What a login form is made of, found in one frame: element handles, each may be null.
|
|
290
|
+
async function controlsIn(frame, s) {
|
|
291
|
+
const handle = await frame.evaluateHandle(
|
|
292
|
+
`(() => {
|
|
293
|
+
${HELPERS}
|
|
294
|
+
const all = deep(document);
|
|
295
|
+
const inputs = all.filter(el => el.tagName === 'INPUT' && visible(el));
|
|
296
|
+
const password = inputs.find(i => i.type === 'password') || null;
|
|
297
|
+
// a one-time-code step: a field that says so, or a row of one-digit boxes
|
|
298
|
+
const digitBoxes = inputs.filter(i => i.maxLength === 1 && /numeric|tel|number/.test(i.inputMode + ' ' + i.type));
|
|
299
|
+
const codeBoxes = digitBoxes.length >= 4 ? digitBoxes : inputs.filter(i => i !== password && i.type !== 'hidden' && (i.autocomplete === 'one-time-code' || CODE.test(describe(i))));
|
|
300
|
+
const score = i => {
|
|
301
|
+
if (['password', 'hidden', 'submit', 'button', 'checkbox', 'radio', 'file', 'image', 'reset', 'search'].includes(i.type) || codeBoxes.includes(i)) return -1;
|
|
302
|
+
let s = 0;
|
|
303
|
+
if (/username|email/.test(i.autocomplete || '')) s += 6;
|
|
304
|
+
if (i.type === 'email') s += 4;
|
|
305
|
+
if (/user|e-?mail|login|account|phone|mobile|\\bid\\b/.test(describe(i))) s += 3;
|
|
306
|
+
if (['text', 'tel', ''].includes(i.type)) s += 1;
|
|
307
|
+
if (password && i.form === password.form && i.compareDocumentPosition(password) & Node.DOCUMENT_POSITION_FOLLOWING) s += 2;
|
|
308
|
+
return s;
|
|
309
|
+
};
|
|
310
|
+
const ranked = inputs.map(i => [score(i), i]).filter(([s]) => s > 0).sort((a, b) => b[0] - a[0]);
|
|
311
|
+
const username = ranked.length ? ranked[0][1] : null;
|
|
312
|
+
const field = password || username || codeBoxes[0];
|
|
313
|
+
const scope = field ? field.form || field.getRootNode() : document;
|
|
314
|
+
const buttons = deep(scope === document ? document : scope).filter(el => /^(BUTTON|INPUT)$/.test(el.tagName) ? ['submit', 'button', ''].includes(el.type) || el.tagName === 'BUTTON' : el.getAttribute('role') === 'button').filter(visible);
|
|
315
|
+
// in a <form>, its submit button; elsewhere only a button that says what it does
|
|
316
|
+
const inForm = Boolean(field && field.form);
|
|
317
|
+
const submit =
|
|
318
|
+
(inForm && buttons.find(b => b.type === 'submit')) ||
|
|
319
|
+
buttons.find(b => /^(sign|log)\\s*-?\\s*in$|^login$|^continue$|^next$|^submit$|^verify$/i.test(label(b))) ||
|
|
320
|
+
buttons.find(b => /sign\\s*-?\\s*in|log\\s*-?\\s*in|continue|next|verify/i.test(label(b))) ||
|
|
321
|
+
null;
|
|
322
|
+
// a link or button that opens the login (a modal, or the identity provider)
|
|
323
|
+
const opener = all.filter(el => /^(A|BUTTON)$/.test(el.tagName) || el.getAttribute('role') === 'button').filter(visible).find(el => /^(sign\\s*-?\\s*in|log\\s*-?\\s*in|login)$/i.test(label(el))) || null;
|
|
324
|
+
const captcha = all.some(el => (el.tagName === 'IFRAME' && /recaptcha|hcaptcha|turnstile|challenges\\.cloudflare|arkoselabs|funcaptcha/i.test(el.src)) || el.matches('.g-recaptcha, .h-captcha, .cf-turnstile, [data-sitekey]'));
|
|
325
|
+
// a challenge a person must answer (an invisible reCAPTCHA needs nothing from anyone)
|
|
326
|
+
const captchaShown = all.some(el => visible(el) && !/size=invisible/.test(el.src || '') && el.getAttribute('data-size') !== 'invisible' &&
|
|
327
|
+
((el.tagName === 'IFRAME' && /recaptcha\\/api2\\/anchor|recaptcha\\/enterprise\\/anchor|hcaptcha|challenges\\.cloudflare|arkoselabs|funcaptcha/i.test(el.src)) || el.matches('.g-recaptcha, .h-captcha, .cf-turnstile')));
|
|
328
|
+
return { username, password, submit, code: codeBoxes[0] || null, codeBoxes: codeBoxes.length, opener, captcha, captchaShown };
|
|
329
|
+
})()`,
|
|
330
|
+
);
|
|
331
|
+
const get = async key => {
|
|
332
|
+
const p = await handle.getProperty(key);
|
|
333
|
+
const el = p.asElement();
|
|
334
|
+
if (!el) {
|
|
335
|
+
const v = await p.jsonValue().catch(() => null);
|
|
336
|
+
await p.dispose();
|
|
337
|
+
return v;
|
|
338
|
+
}
|
|
339
|
+
return el;
|
|
340
|
+
};
|
|
341
|
+
const pick = async (key, selector) => (selector ? frame.$(selector).catch(() => null) : get(key));
|
|
342
|
+
const c = {
|
|
343
|
+
frame,
|
|
344
|
+
username: await pick('username', s.usernameSelector),
|
|
345
|
+
password: await pick('password', s.passwordSelector),
|
|
346
|
+
submit: await pick('submit', s.submitSelector),
|
|
347
|
+
code: await pick('code', s.codeSelector),
|
|
348
|
+
codeBoxes: await get('codeBoxes'),
|
|
349
|
+
opener: await get('opener'),
|
|
350
|
+
captcha: await get('captcha'),
|
|
351
|
+
captchaShown: await get('captchaShown'),
|
|
352
|
+
};
|
|
353
|
+
await handle.dispose();
|
|
354
|
+
return c;
|
|
355
|
+
}
|
|
356
|
+
|
|
357
|
+
// The frame that holds the login form, or the main frame's findings when none does.
|
|
358
|
+
async function findControls(page, s) {
|
|
359
|
+
let first = null;
|
|
360
|
+
for (const frame of framesOf(page)) {
|
|
361
|
+
let c;
|
|
362
|
+
try {
|
|
363
|
+
c = await controlsIn(frame, s);
|
|
364
|
+
} catch {
|
|
365
|
+
continue; // a frame that is navigating or gone
|
|
366
|
+
}
|
|
367
|
+
first ??= c;
|
|
368
|
+
if (c.password || c.username || c.code) return c;
|
|
369
|
+
}
|
|
370
|
+
return first || { username: null, password: null, submit: null, code: null, opener: null, captcha: false };
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
// Replace a field's value the way a person would, so framework-controlled inputs see it.
|
|
374
|
+
async function fill(page, field, value) {
|
|
375
|
+
await field.click({ count: 3 });
|
|
376
|
+
await field.evaluate(el => {
|
|
377
|
+
try {
|
|
378
|
+
el.select();
|
|
379
|
+
} catch {}
|
|
380
|
+
});
|
|
381
|
+
await page.keyboard.press('Backspace');
|
|
382
|
+
await field.type(value, { delay: 12 });
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Whether any frame says the details were wrong. Compared before and after
|
|
386
|
+
// submitting, so text that was already on the page never counts.
|
|
387
|
+
const REJECTED = /\b(wrong|invalid|incorrect|not recogni[sz]ed|doesn.t match|does not match|couldn.t sign you in|could not sign you in|unable to (?:sign|log) (?:you )?in|failed to (?:sign|log) in)\b/i;
|
|
388
|
+
async function saysRejected(page) {
|
|
389
|
+
for (const frame of framesOf(page)) {
|
|
390
|
+
try {
|
|
391
|
+
if (await frame.evaluate(`(() => ${REJECTED}.test(document.body ? document.body.innerText : ''))()`)) return true;
|
|
392
|
+
} catch {}
|
|
393
|
+
}
|
|
394
|
+
return false;
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
async function submit(page, c, lastField) {
|
|
398
|
+
if (c.submit) await c.submit.click().catch(() => lastField?.press('Enter'));
|
|
399
|
+
else await lastField?.press('Enter');
|
|
400
|
+
}
|
|
401
|
+
|
|
402
|
+
// Polls until `check()` is true or the time runs out. A page that is navigating
|
|
403
|
+
// throws "execution context was destroyed": that just means "ask again".
|
|
404
|
+
async function waitUntil(check, timeoutMs, stepMs = 250) {
|
|
405
|
+
const end = Date.now() + timeoutMs;
|
|
406
|
+
while (Date.now() < end) {
|
|
407
|
+
try {
|
|
408
|
+
if (await check()) return true;
|
|
409
|
+
} catch {}
|
|
410
|
+
await sleep(stepMs);
|
|
411
|
+
}
|
|
412
|
+
return false;
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
const where = page => {
|
|
416
|
+
try {
|
|
417
|
+
const u = new URL(page.url());
|
|
418
|
+
return `${u.origin}${u.pathname}`;
|
|
419
|
+
} catch {
|
|
420
|
+
return page.url();
|
|
421
|
+
}
|
|
422
|
+
};
|
|
423
|
+
const captchaError = page =>
|
|
424
|
+
new Error(`The login on ${where(page)} shows a CAPTCHA, which Spectra does not solve. Choose "Sign in myself" and sign in in Spectra's Chrome window.`);
|
|
425
|
+
|
|
426
|
+
/**
|
|
427
|
+
* Signs in through a login form on `page`: opens `signIn.url` (if given),
|
|
428
|
+
* clicks the "click first" control or a plain "Sign in" button when no form is
|
|
429
|
+
* showing, types the username and password (a two-step form gets the
|
|
430
|
+
* username, then Next, then the password), answers an authenticator-code step
|
|
431
|
+
* when a TOTP secret is given, and waits until the login form is gone. Works
|
|
432
|
+
* in iframes and web components, and on identity providers the app redirects
|
|
433
|
+
* to. Throws a readable Error that never contains a secret.
|
|
434
|
+
*/
|
|
435
|
+
export async function signInWithForm(page, signIn, { timeoutMs = 30_000 } = {}) {
|
|
436
|
+
const s = normalizeSignIn(signIn);
|
|
437
|
+
if (!hasCredentials(s)) throw new Error('No username or password to sign in with.');
|
|
438
|
+
if (s.url) await page.goto(s.url, { waitUntil: 'domcontentloaded', timeout: 60_000 });
|
|
439
|
+
if (s.startSelector) {
|
|
440
|
+
// e.g. "Sign in with Okta", or the button that opens a login modal
|
|
441
|
+
const start = await waitUntil(async () => Boolean(await page.$(s.startSelector)), 15_000);
|
|
442
|
+
if (!start) throw new Error(`The "click first" control (${s.startSelector}) was not found on ${where(page)}.`);
|
|
443
|
+
await page.click(s.startSelector);
|
|
444
|
+
}
|
|
445
|
+
|
|
446
|
+
let c = null;
|
|
447
|
+
let clickedOpener = Boolean(s.startSelector);
|
|
448
|
+
const ready = await waitUntil(async () => {
|
|
449
|
+
c = await findControls(page, s);
|
|
450
|
+
if (c.password || c.username) return true;
|
|
451
|
+
// no form yet: a plain "Sign in" button may open it (a modal, or the provider)
|
|
452
|
+
if (!clickedOpener && c.opener) {
|
|
453
|
+
clickedOpener = true;
|
|
454
|
+
await c.opener.click().catch(() => {});
|
|
455
|
+
}
|
|
456
|
+
return false;
|
|
457
|
+
}, 20_000);
|
|
458
|
+
if (!ready) {
|
|
459
|
+
if (c?.captcha) throw captchaError(page);
|
|
460
|
+
throw new Error(`No login form was found on ${where(page)}. Check the login page URL, or give the field selectors.`);
|
|
461
|
+
}
|
|
462
|
+
// a challenge for a person: don't even send the details
|
|
463
|
+
if (c.captchaShown) throw captchaError(page);
|
|
464
|
+
const rejectedBefore = await saysRejected(page);
|
|
465
|
+
|
|
466
|
+
if (s.username && c.username) await fill(page, c.username, s.username);
|
|
467
|
+
if (!c.password && s.password) {
|
|
468
|
+
// two steps: the username first, then the password on the next screen
|
|
469
|
+
await submit(page, c, c.username);
|
|
470
|
+
const next = await waitUntil(async () => {
|
|
471
|
+
c = await findControls(page, s);
|
|
472
|
+
return Boolean(c.password);
|
|
473
|
+
}, 20_000);
|
|
474
|
+
if (!next) {
|
|
475
|
+
if (c.captcha) throw captchaError(page);
|
|
476
|
+
if (!rejectedBefore && (await saysRejected(page)))
|
|
477
|
+
throw new Error(`The login on ${where(page)} did not accept the username. Check it.`);
|
|
478
|
+
throw new Error(`After the username, no password field appeared on ${where(page)}. If this login uses a one-time code sent by SMS or email, or a passkey, choose "Sign in myself".`);
|
|
479
|
+
}
|
|
480
|
+
}
|
|
481
|
+
if (s.password && c.password) await fill(page, c.password, s.password);
|
|
482
|
+
await submit(page, c, c.password || c.username);
|
|
483
|
+
|
|
484
|
+
// the form leaves, or asks for an authenticator code first
|
|
485
|
+
const end = Date.now() + timeoutMs;
|
|
486
|
+
const submittedAt = Date.now();
|
|
487
|
+
let answeredCode = false;
|
|
488
|
+
while (Date.now() < end) {
|
|
489
|
+
// the app answered "wrong password": no need to wait out the timeout
|
|
490
|
+
if (Date.now() - submittedAt > 1000 && !rejectedBefore && (await hasLoginForm(page)) && (await saysRejected(page)))
|
|
491
|
+
throw new Error(`The login on ${where(page)} did not accept the username and password. Check them.`);
|
|
492
|
+
if (!(await hasLoginForm(page))) {
|
|
493
|
+
const after = await findControls(page, s).catch(() => null);
|
|
494
|
+
if (after?.code && !answeredCode) {
|
|
495
|
+
if (!s.totpSecret) {
|
|
496
|
+
// only a login step: an app page can have a "verification" field of its own
|
|
497
|
+
if (/login|sign-?in|auth|mfa|2fa|verify|otp|challenge/i.test(page.url()))
|
|
498
|
+
throw new Error(`The login on ${where(page)} asks for a one-time code. Give the authenticator (TOTP) secret, or choose "Sign in myself".`);
|
|
499
|
+
break;
|
|
500
|
+
}
|
|
501
|
+
const code = totp(s.totpSecret);
|
|
502
|
+
if (after.codeBoxes > 1) {
|
|
503
|
+
// one box per digit: typing into the first usually moves along; fill each to be sure
|
|
504
|
+
await after.code.click();
|
|
505
|
+
for (const digit of code) await page.keyboard.type(digit, { delay: 30 });
|
|
506
|
+
} else await fill(page, after.code, code);
|
|
507
|
+
answeredCode = true;
|
|
508
|
+
await submit(page, after, after.code);
|
|
509
|
+
await sleep(800);
|
|
510
|
+
continue;
|
|
511
|
+
}
|
|
512
|
+
if (!after?.code) break;
|
|
513
|
+
}
|
|
514
|
+
await sleep(300);
|
|
515
|
+
}
|
|
516
|
+
if (await hasLoginForm(page)) {
|
|
517
|
+
const now = await findControls(page, s).catch(() => null);
|
|
518
|
+
if (now?.captcha) throw captchaError(page);
|
|
519
|
+
throw new Error(`Spectra filled in the login form on ${where(page)}, but it is still showing after ${Math.round(timeoutMs / 1000)} s. Check the username and password, or give the field selectors.`);
|
|
520
|
+
}
|
|
521
|
+
// let the app finish what follows a login (redirects, first data loads)
|
|
522
|
+
await page.waitForNetworkIdle({ idleTime: 400, timeout: 8_000 }).catch(() => {});
|
|
523
|
+
return { url: page.url() };
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* Waits while a person signs in in the visible Chrome window: until the page
|
|
528
|
+
* no longer shows a login form or a login URL. Used for logins Spectra must
|
|
529
|
+
* not automate: SMS or email codes, push approvals, passkeys, CAPTCHAs.
|
|
530
|
+
*/
|
|
531
|
+
export async function waitForManualSignIn(page, { loginUrl = '', timeoutMs = 5 * 60_000, onWaiting } = {}) {
|
|
532
|
+
onWaiting?.();
|
|
533
|
+
const loginPath = (() => {
|
|
534
|
+
try {
|
|
535
|
+
return loginUrl ? new URL(loginUrl).origin + new URL(loginUrl).pathname : '';
|
|
536
|
+
} catch {
|
|
537
|
+
return '';
|
|
538
|
+
}
|
|
539
|
+
})();
|
|
540
|
+
const done = await waitUntil(async () => {
|
|
541
|
+
if (await hasLoginForm(page)) return false;
|
|
542
|
+
const now = where(page);
|
|
543
|
+
if (loginPath && now === loginPath) return false;
|
|
544
|
+
return !looksSignedOut(null, page.url());
|
|
545
|
+
}, timeoutMs, 1000);
|
|
546
|
+
if (!done) throw new Error(`Waited ${Math.round(timeoutMs / 60_000)} min for you to sign in in Spectra's Chrome window, but the login is still showing.`);
|
|
547
|
+
await page.waitForNetworkIdle({ idleTime: 500, timeout: 10_000 }).catch(() => {});
|
|
548
|
+
return { url: page.url() };
|
|
549
|
+
}
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Teaching AI agents about Spectra: what is written where, that it is
|
|
2
|
+
// idempotent, never touches the user's own text or files, comes back out
|
|
3
|
+
// cleanly, and that the install step only runs for a real project install.
|
|
4
|
+
import assert from 'node:assert/strict';
|
|
5
|
+
import fs from 'node:fs';
|
|
6
|
+
import os from 'node:os';
|
|
7
|
+
import path from 'node:path';
|
|
8
|
+
import { test } from 'node:test';
|
|
9
|
+
import { TARGET_IDS, detectTargets, installAgentDocs, knowledge, postinstallPlan, removeAgentDocs } from './agents.js';
|
|
10
|
+
|
|
11
|
+
const tmp = () => fs.realpathSync(fs.mkdtempSync(path.join(os.tmpdir(), 'spectra-agents-')));
|
|
12
|
+
const project = (files = {}) => {
|
|
13
|
+
const root = tmp();
|
|
14
|
+
fs.writeFileSync(path.join(root, 'package.json'), '{"name":"acme"}');
|
|
15
|
+
for (const [f, text] of Object.entries(files)) {
|
|
16
|
+
fs.mkdirSync(path.dirname(path.join(root, f)), { recursive: true });
|
|
17
|
+
fs.writeFileSync(path.join(root, f), text);
|
|
18
|
+
}
|
|
19
|
+
return root;
|
|
20
|
+
};
|
|
21
|
+
const read = (root, f) => fs.readFileSync(path.join(root, f), 'utf8');
|
|
22
|
+
const has = (root, f) => fs.existsSync(path.join(root, f));
|
|
23
|
+
|
|
24
|
+
test('the knowledge doc covers what an agent needs', () => {
|
|
25
|
+
const doc = knowledge();
|
|
26
|
+
for (const s of ['npx spectra performance', 'spectra compare', '--json', 'summary', 'findings', 'spectra.config.json', 'SPECTRA_LOGIN_PASSWORD', 'Never ask the user to paste a password'])
|
|
27
|
+
assert.ok(doc.includes(s), `missing: ${s}`);
|
|
28
|
+
// one level deeper inside a shared file
|
|
29
|
+
assert.match(knowledge({ nested: true }), /^## Spectra/);
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
test('every agent gets its own file, with the format it reads', () => {
|
|
33
|
+
const root = project();
|
|
34
|
+
const results = installAgentDocs({ root, targets: TARGET_IDS });
|
|
35
|
+
assert.ok(results.every(r => r.result === 'created'), JSON.stringify(results));
|
|
36
|
+
assert.match(read(root, '.claude/skills/spectra/SKILL.md'), /^---\nname: spectra\ndescription: "Spectra/);
|
|
37
|
+
assert.match(read(root, '.cursor/rules/spectra.mdc'), /^---\ndescription: ".+"\nglobs:\nalwaysApply: false\n---/);
|
|
38
|
+
assert.match(read(root, '.github/instructions/spectra.instructions.md'), /^---\napplyTo: "\*\*"\n/);
|
|
39
|
+
assert.match(read(root, '.windsurf/rules/spectra.md'), /^---\ntrigger: model_decision\n/);
|
|
40
|
+
for (const f of ['AGENTS.md', 'CLAUDE.md', 'GEMINI.md']) assert.match(read(root, f), /<!-- spectra:start -->[\s\S]+<!-- spectra:end -->/);
|
|
41
|
+
// a second run changes nothing
|
|
42
|
+
assert.ok(installAgentDocs({ root, targets: TARGET_IDS }).every(r => r.result === 'unchanged'));
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("the user's own text and files are left alone", () => {
|
|
46
|
+
const mine = '# Our agents\n\nUse pnpm, never npm.\n';
|
|
47
|
+
const root = project({ 'AGENTS.md': mine, '.cursor/rules/spectra.mdc': 'my own rule\n' });
|
|
48
|
+
const results = installAgentDocs({ root, targets: ['agents-md', 'cursor'] });
|
|
49
|
+
assert.equal(results.find(r => r.id === 'cursor').result, 'skipped (a file of yours is already there)');
|
|
50
|
+
assert.equal(read(root, '.cursor/rules/spectra.mdc'), 'my own rule\n');
|
|
51
|
+
const agents = read(root, 'AGENTS.md');
|
|
52
|
+
assert.ok(agents.startsWith(mine.trim()));
|
|
53
|
+
// the user edits around the block; an update replaces only the block
|
|
54
|
+
fs.writeFileSync(path.join(root, 'AGENTS.md'), `${agents}\n## Later\n\nMore notes.\n`);
|
|
55
|
+
const edited = read(root, 'AGENTS.md').replace('Spectra: frontend performance, measured', 'an older version');
|
|
56
|
+
fs.writeFileSync(path.join(root, 'AGENTS.md'), edited);
|
|
57
|
+
assert.equal(installAgentDocs({ root, targets: ['agents-md'] })[0].result, 'updated');
|
|
58
|
+
const updated = read(root, 'AGENTS.md');
|
|
59
|
+
assert.ok(updated.startsWith(mine.trim()) && updated.endsWith('## Later\n\nMore notes.\n'));
|
|
60
|
+
assert.equal(updated.match(/spectra:start/g).length, 1);
|
|
61
|
+
// and removing it gives the user's text back
|
|
62
|
+
removeAgentDocs({ root });
|
|
63
|
+
assert.equal(read(root, 'AGENTS.md'), `${mine.trim()}\n\n## Later\n\nMore notes.\n`);
|
|
64
|
+
assert.equal(read(root, '.cursor/rules/spectra.mdc'), 'my own rule\n');
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
test('removing deletes what Spectra made, and the folders it made', () => {
|
|
68
|
+
const root = project({ '.github/workflows/ci.yml': 'on: push\n' });
|
|
69
|
+
installAgentDocs({ root, targets: TARGET_IDS });
|
|
70
|
+
const removed = removeAgentDocs({ root });
|
|
71
|
+
assert.equal(removed.length, TARGET_IDS.length + 1); // and CLAUDE.md
|
|
72
|
+
for (const f of ['AGENTS.md', 'CLAUDE.md', 'GEMINI.md', '.claude', '.cursor', '.windsurf', '.clinerules', '.roo', '.github/instructions']) assert.equal(has(root, f), false, f);
|
|
73
|
+
assert.equal(read(root, '.github/workflows/ci.yml'), 'on: push\n');
|
|
74
|
+
assert.deepEqual(removeAgentDocs({ root }), []);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('the agents in use are detected from the project and the home folder', () => {
|
|
78
|
+
const home = tmp();
|
|
79
|
+
const root = project({ '.cursor/mcp.json': '{}' });
|
|
80
|
+
assert.deepEqual(detectTargets({ root, home }), ['agents-md', 'cursor']);
|
|
81
|
+
fs.mkdirSync(path.join(home, '.claude'));
|
|
82
|
+
fs.mkdirSync(path.join(home, '.vscode'));
|
|
83
|
+
assert.deepEqual(detectTargets({ root, home }), ['agents-md', 'claude', 'cursor', 'copilot']);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
test('the install step runs only for a real project install', () => {
|
|
87
|
+
const root = project();
|
|
88
|
+
const pkgRoot = path.join(root, 'node_modules', '@progen-labs', 'spectra');
|
|
89
|
+
fs.mkdirSync(pkgRoot, { recursive: true });
|
|
90
|
+
const env = { INIT_CWD: root };
|
|
91
|
+
assert.deepEqual(postinstallPlan({ env, pkgRoot }), { root, targets: undefined });
|
|
92
|
+
assert.match(postinstallPlan({ env: { ...env, CI: 'true' }, pkgRoot }).skip, /CI/);
|
|
93
|
+
assert.match(postinstallPlan({ env: { ...env, SPECTRA_AGENT_DOCS: '0' }, pkgRoot }).skip, /SPECTRA_AGENT_DOCS/);
|
|
94
|
+
assert.match(postinstallPlan({ env: { ...env, npm_config_global: 'true' }, pkgRoot }).skip, /global/);
|
|
95
|
+
assert.match(postinstallPlan({ env: {}, pkgRoot }).skip, /INIT_CWD/);
|
|
96
|
+
// npx runs Spectra from its cache, not from the project
|
|
97
|
+
assert.match(postinstallPlan({ env, pkgRoot: path.join(tmp(), '_npx', 'node_modules', '@progen-labs', 'spectra') }).skip, /not installed into this project/);
|
|
98
|
+
// installing Spectra's own dependencies
|
|
99
|
+
assert.match(postinstallPlan({ env: { INIT_CWD: pkgRoot }, pkgRoot }).skip, /own repository/);
|
|
100
|
+
// the project can turn it off, or pick the agents
|
|
101
|
+
fs.writeFileSync(path.join(root, 'spectra.config.json'), '{ "agentDocs": false }');
|
|
102
|
+
assert.match(postinstallPlan({ env, pkgRoot }).skip, /agentDocs/);
|
|
103
|
+
fs.writeFileSync(path.join(root, 'spectra.config.json'), '{ "agentDocs": ["copilot", "nope"] }');
|
|
104
|
+
assert.deepEqual(postinstallPlan({ env, pkgRoot }).targets, ['copilot']);
|
|
105
|
+
});
|