@botbuddy/cli 1.64.34 → 1.66.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.
@@ -0,0 +1,239 @@
1
+ // BOT-3145 (ported from supplyspark-ent ENT-5553) test helper: a scripted stand-in for Playwright's chromium.launch() that plays
2
+ // the real Okta tenant. The identifier screen is built from the 2026-10-14 real capture
3
+ // (fixtures/2026-10-14-okta-sso-signin.json), so the okta-steps selectors are tested
4
+ // against the markup Okta actually serves; the later screens follow Okta Sign-In Widget 7.x.
5
+
6
+ import { readFileSync } from "node:fs";
7
+
8
+ export const OKTA_FIXTURE = JSON.parse(
9
+ readFileSync(new URL("../fixtures/2026-10-14-okta-sso-signin.json", import.meta.url), "utf8"),
10
+ );
11
+
12
+ const OKTA_HOST = OKTA_FIXTURE.okta.host;
13
+ export const ACS_URL = OKTA_FIXTURE.gotrue.acs.request.replace(/^POST /, "");
14
+
15
+ function identifierScreen() {
16
+ const visible = new Map();
17
+ for (const input of OKTA_FIXTURE.okta.identifierScreen.inputs) {
18
+ if (input.type === "hidden") continue;
19
+ if (input.name) visible.set(`input[name="${input.name}"]`, "");
20
+ if (input.type === "submit") visible.set('input[type="submit"]', input.value);
21
+ }
22
+ visible.set('[data-se="o-form-head"]', OKTA_FIXTURE.okta.identifierScreen.visibleHeading);
23
+ return { name: "identifier", visible };
24
+ }
25
+
26
+ const passwordVisible = () =>
27
+ new Map([
28
+ ['input[type="password"]', ""],
29
+ ['input[name="credentials.passcode"]', ""],
30
+ ['input[type="submit"]', "Verify"],
31
+ ['[data-se="o-form-head"]', "Verify with your password"],
32
+ ]);
33
+
34
+ const SCREENS = {
35
+ identifier: identifierScreen,
36
+ password: () => ({ name: "password", visible: passwordVisible() }),
37
+ password_error: () => {
38
+ const visible = passwordVisible();
39
+ visible.set('[data-se="o-form-error-container"]', "Password is incorrect");
40
+ return { name: "password_error", visible };
41
+ },
42
+ locked: () => {
43
+ const visible = passwordVisible();
44
+ visible.set('[data-se="o-form-error-container"]', "Your account is locked. Please contact your administrator.");
45
+ return { name: "locked", visible };
46
+ },
47
+ mfa: () => ({
48
+ name: "mfa",
49
+ visible: new Map([
50
+ ['[data-se="o-form-head"]', "Verify it's you with a security method"],
51
+ ['[data-se="authenticator-verify-list"]', "Get a push notification Okta Verify"],
52
+ ]),
53
+ }),
54
+ totp_challenge: () => ({
55
+ name: "totp_challenge",
56
+ visible: new Map([
57
+ ['[data-se="o-form-head"]', "Verify with Google Authenticator"],
58
+ ['input[name="credentials.passcode"]', ""],
59
+ ['input[type="submit"]', "Verify"],
60
+ ]),
61
+ }),
62
+ blank: () => ({ name: "blank", visible: new Map() }),
63
+ };
64
+
65
+ const splitSelector = (selector) => selector.split(/,\s*/).map((part) => part.trim());
66
+
67
+ // Playwright URL glob: `**` any characters, `*` any except "/".
68
+ function globToRegExp(glob) {
69
+ const escaped = glob.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*\*/g, "\u0000").replace(/\*/g, "[^/]*").replace(/\u0000/g, ".*");
70
+ return new RegExp(`^${escaped}$`);
71
+ }
72
+
73
+ /**
74
+ * outcome after the password submit (Okta auto-posts the SAML assertion to GoTrue's ACS):
75
+ * "success" → ACS answers 302 → app origin with a session fragment (`landingHash`)
76
+ * "callback_error" → ACS answers 302 → app origin with a GoTrue error fragment (`landingHash`)
77
+ * "acs_error_body" → ACS answers 422 with a GoTrue JSON error (`acsBody`)
78
+ * "password_error" | "locked" | "mfa" | "totp_challenge" → that Okta screen
79
+ * "hang" → nothing happens
80
+ * redirectHost: serve the identifier screen on another host (a look-alike). gotoError: page.goto throws it.
81
+ * "stuck_off_okta" → after Verify the browser leaves Okta and nothing ever answers
82
+ * "click_throws" → the Verify click throws after the password was filled
83
+ * "blank_on_okta" → after Verify Okta shows neither a form nor an error, on its own host
84
+ *
85
+ * Like real Playwright, a route on the ACS sees the POST; the 302's target is a redirect, which
86
+ * Playwright never routes — an unintercepted ACS therefore lands on the app (`log.appBooted`).
87
+ */
88
+ export function createFakeOktaBrowser({ appOrigin, outcomes = ["success"], landingHash, acsBody = "", redirectHost, acsUrl = ACS_URL, gotoError } = {}) {
89
+ const log = { launches: 0, closes: 0, typed: [], routes: [], landings: [], acsFetches: [], blankFulfilled: 0, gotos: [], appBooted: 0 };
90
+ let launchIndex = 0;
91
+
92
+ function newPage(context) {
93
+ const handlers = [];
94
+ const frame = { url: () => page._url };
95
+ let screen = SCREENS.blank();
96
+ const outcome = outcomes[Math.min(launchIndex - 1, outcomes.length - 1)];
97
+
98
+ function commit(url) {
99
+ page._url = url;
100
+ for (const handler of handlers) handler(frame);
101
+ }
102
+
103
+ function acsResponse() {
104
+ if (outcome === "acs_error_body") return { status: 422, headers: { "content-type": "application/json" }, body: acsBody };
105
+ // Real staging (fixture): success is a 302, an ACS error a 303 redirect with error params.
106
+ const status = outcome === "callback_error" ? OKTA_FIXTURE.gotrue.acs.errorRedirect.status : OKTA_FIXTURE.gotrue.acs.status;
107
+ return { status, headers: { location: `${appOrigin}/#${landingHash}` }, body: "" };
108
+ }
109
+
110
+ async function postToAcs() {
111
+ const response = acsResponse();
112
+ const routed = context.routes.find((route) => globToRegExp(route.pattern).test(acsUrl));
113
+ if (routed) {
114
+ let fulfilled = false;
115
+ await routed.handler({
116
+ request: () => ({ url: () => acsUrl, method: () => "POST" }),
117
+ async fetch(options = {}) {
118
+ log.acsFetches.push(options);
119
+ if (options.maxRedirects !== 0) throw new Error("the recipe must fetch the ACS without following its redirect");
120
+ return { status: () => response.status, headers: () => response.headers, text: async () => response.body };
121
+ },
122
+ async fulfill() {
123
+ fulfilled = true;
124
+ log.blankFulfilled += 1;
125
+ },
126
+ });
127
+ if (!fulfilled) throw new Error("ACS route neither fulfilled nor continued");
128
+ screen = SCREENS.blank();
129
+ commit(acsUrl);
130
+ return;
131
+ }
132
+ // Not intercepted: the browser follows the 302 and the real app boots on the fragment.
133
+ if (response.status === 302 || response.status === 303) {
134
+ log.appBooted += 1;
135
+ log.landings.push(response.headers.location);
136
+ screen = SCREENS.blank();
137
+ commit(response.headers.location);
138
+ } else {
139
+ screen = SCREENS.blank();
140
+ commit(acsUrl);
141
+ }
142
+ }
143
+
144
+ async function afterPasswordSubmit() {
145
+ if (outcome === "success" || outcome === "callback_error" || outcome === "acs_error_body") {
146
+ await postToAcs();
147
+ } else if (outcome === "click_throws") {
148
+ // BOT-3145 review: the Verify click itself fails after the password went in.
149
+ throw new Error("locator.click: Target page, context or browser has been closed");
150
+ } else if (outcome === "blank_on_okta") {
151
+ // BOT-3145 review: Okta stays on its own host with no form and no error after Verify.
152
+ screen = SCREENS.blank();
153
+ } else if (outcome === "about_blank") {
154
+ // BOT-3145 review round 2: the tab goes blank after Verify (no host, proves nothing).
155
+ screen = SCREENS.blank();
156
+ commit("about:blank");
157
+ } else if (outcome === "stuck_off_okta") {
158
+ // Okta moved on (e.g. to GoTrue) but nothing ever answers.
159
+ screen = SCREENS.blank();
160
+ commit("https://staging-api.supply-guard.io/auth/v1/sso/saml/pending");
161
+ } else if (outcome !== "hang") {
162
+ screen = SCREENS[outcome]();
163
+ }
164
+ }
165
+
166
+ const locator = (selector) => {
167
+ const parts = splitSelector(selector);
168
+ const match = () => parts.find((part) => screen.visible.has(part));
169
+ const api = {
170
+ first: () => api,
171
+ async isVisible() {
172
+ return Boolean(match());
173
+ },
174
+ async waitFor() {
175
+ if (!match()) throw new Error(`Timeout waiting for ${selector} on ${screen.name}`);
176
+ },
177
+ async innerText() {
178
+ const part = match();
179
+ if (!part) throw new Error("not visible");
180
+ return screen.visible.get(part);
181
+ },
182
+ async fill(value) {
183
+ const part = match();
184
+ if (!part) throw new Error(`fill: ${selector} not visible`);
185
+ log.typed.push({ host: new URL(page._url).hostname, field: part.includes("identifier") ? "identifier" : "password", value });
186
+ },
187
+ async click() {
188
+ if (screen.name === "identifier") screen = SCREENS.password();
189
+ else if (screen.name === "password") await afterPasswordSubmit();
190
+ },
191
+ };
192
+ return api;
193
+ };
194
+
195
+ const page = {
196
+ _url: "about:blank",
197
+ on(event, handler) {
198
+ if (event === "framenavigated") handlers.push(handler);
199
+ },
200
+ mainFrame: () => frame,
201
+ url: () => page._url,
202
+ locator,
203
+ async goto(url) {
204
+ log.gotos.push(url);
205
+ if (gotoError) throw gotoError;
206
+ const parsed = new URL(url);
207
+ if (redirectHost) parsed.hostname = redirectHost;
208
+ commit(parsed.toString());
209
+ if (parsed.hostname === OKTA_HOST || redirectHost) screen = SCREENS.identifier();
210
+ },
211
+ };
212
+ return page;
213
+ }
214
+
215
+ async function launchBrowser() {
216
+ log.launches += 1;
217
+ launchIndex += 1;
218
+ return {
219
+ async newContext() {
220
+ const context = {
221
+ routes: [],
222
+ async route(pattern, handler) {
223
+ context.routes.push({ pattern, handler });
224
+ log.routes.push(pattern);
225
+ },
226
+ async newPage() {
227
+ return newPage(context);
228
+ },
229
+ };
230
+ return context;
231
+ },
232
+ async close() {
233
+ log.closes += 1;
234
+ },
235
+ };
236
+ }
237
+
238
+ return { launchBrowser, log };
239
+ }
package/src/wait-core.mjs CHANGED
@@ -116,6 +116,9 @@ export const SIGNAL_TYPES = Object.freeze([
116
116
  // It is intentionally consumed through the existing raw `event:` escape
117
117
  // hatch rather than adding another parallel wait grammar.
118
118
  "host_approval",
119
+ // BOT-3136: one signal per member-issue change of a Linear project (an epic),
120
+ // subject = the project's lower-case UUID. Tenant-primary like linear_issue.
121
+ "linear_project",
119
122
  ]);
120
123
 
121
124
  const LOCK_SUBTYPES = Object.freeze(["playwright_lane", "vite_port", "backend_port", "supabase_local"]);
@@ -525,6 +528,15 @@ const VALIDATORS = {
525
528
  if (p.type === "host_approval" && (!subject || !HOST_APPROVAL_HOST_KEY_RE.test(subject))) {
526
529
  throw new Error("event:type=host_approval needs subject=<canonical bbh- host key>");
527
530
  }
531
+ // BOT-3136: a project subject is its Linear UUID, matched exactly as the
532
+ // emitter writes it (lower-case), so normalize here and refuse anything else.
533
+ if (p.type === "linear_project") {
534
+ const id = subject ? subject.toLowerCase() : "";
535
+ if (!CANONICAL_UUID_RE.test(id)) {
536
+ throw new Error("event:type=linear_project needs subject=<Linear project UUID>");
537
+ }
538
+ return { type: p.type, subject: id };
539
+ }
528
540
  return { type: p.type, subject };
529
541
  },
530
542
  };
package/src/wait.mjs CHANGED
@@ -251,6 +251,10 @@ CONDITIONS (TYPE:key=val,key=val — repeat for several; --any wakes on the fir
251
251
  event:type=host_approval,subject=bbh-<24 lowercase hex>;
252
252
  it wakes only for the authenticated owner's one bound
253
253
  tenant after current server-side approval revalidation.
254
+ Epic progress uses
255
+ event:type=linear_project,subject=<Linear project UUID>;
256
+ it wakes when any issue in that project changes, joins
257
+ or leaves it (the band's epic row, BOT-3136).
254
258
 
255
259
  OPTIONS
256
260
  Review intervention: exact-head Codex waits have a durable one-shot diagnostic.