@treeseed/sdk 0.12.7 → 0.12.9

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.
@@ -57,6 +57,27 @@ function extractConfirmationUrl(body) {
57
57
  const relative = body.match(/(?:\/auth\/confirm-email\?[^"' <>\n]+|\/team-invites\/[^"' <>\n]+\/accept)/u)?.[0];
58
58
  return relative ?? null;
59
59
  }
60
+ async function sleep(ms) {
61
+ await new Promise((resolve) => setTimeout(resolve, ms));
62
+ }
63
+ function isRetryableNavigationError(error) {
64
+ const message = error instanceof Error ? error.message : String(error ?? "");
65
+ return /Timeout|ERR_CONNECTION|ECONNRESET|ECONNREFUSED|ETIMEDOUT|503|502|504/iu.test(message);
66
+ }
67
+ async function navigateScenePage(page, url) {
68
+ let lastError = null;
69
+ for (let attempt = 1; attempt <= 3; attempt += 1) {
70
+ try {
71
+ await page.goto(url, { waitUntil: "domcontentloaded", timeout: 45e3 });
72
+ return;
73
+ } catch (error) {
74
+ lastError = error;
75
+ if (!isRetryableNavigationError(error) || attempt >= 3) break;
76
+ await sleep(500 * attempt);
77
+ }
78
+ }
79
+ throw lastError instanceof Error ? lastError : new Error(String(lastError ?? `Navigation failed for ${url}`));
80
+ }
60
81
  async function assertionReport(kind, action, selector) {
61
82
  const startedAt = /* @__PURE__ */ new Date();
62
83
  try {
@@ -85,7 +106,7 @@ function createBuiltInTreeseedScenePlugins() {
85
106
  summary: "Navigate to a route or absolute URL.",
86
107
  async run({ action, context }) {
87
108
  if (!("goto" in action)) return { ok: false, diagnostics: [sceneErrorDiagnostic("scene.invalid_action", "Expected goto action.", "workflow.action.goto")] };
88
- await context.session.page.goto(context.resolveUrl(action.goto));
109
+ await navigateScenePage(context.session.page, context.resolveUrl(action.goto));
89
110
  return { ok: true, diagnostics: [] };
90
111
  }
91
112
  },
@@ -219,18 +240,18 @@ function createBuiltInTreeseedScenePlugins() {
219
240
  search.searchParams.set("query", `to:${email}`);
220
241
  context.timeline.push("mailpit.inbox.open", { messageId: id, email, url: search.toString() }, step.id);
221
242
  context.progress?.push("mailpit.inbox.open", { messageId: id, email }, { stepId: step.id });
222
- await context.session.page.goto(search.toString());
243
+ await navigateScenePage(context.session.page, search.toString());
223
244
  await context.sleep(displayInboxSeconds * 1e3);
224
245
  }
225
246
  if (displayMessageSeconds && displayMessageSeconds > 0) {
226
247
  const view = new URL(`view/${encodeURIComponent(id)}`, mailpitBase);
227
248
  context.timeline.push("mailpit.message.open", { messageId: id, email, url: view.toString() }, step.id);
228
249
  context.progress?.push("mailpit.message.open", { messageId: id, email }, { stepId: step.id });
229
- await context.session.page.goto(view.toString());
250
+ await navigateScenePage(context.session.page, view.toString());
230
251
  await context.sleep(displayMessageSeconds * 1e3);
231
252
  }
232
253
  context.timeline.push("mailpit.confirm.open", { messageId: id, email, url: resolvedUrl }, step.id);
233
- await context.session.page.goto(resolvedUrl);
254
+ await navigateScenePage(context.session.page, resolvedUrl);
234
255
  return { ok: true, diagnostics: [] };
235
256
  } catch (error) {
236
257
  return { ok: false, diagnostics: [sceneErrorDiagnostic("scene.mailpit_unavailable", error instanceof Error ? error.message : String(error ?? "Mailpit confirmation failed."), `workflow.${step.id}.action.mailpitConfirmLatest`)] };
@@ -1809,7 +1809,10 @@ export type TreeseedSceneLocator = {
1809
1809
  isVisible(): Promise<boolean>;
1810
1810
  };
1811
1811
  export type TreeseedScenePage = {
1812
- goto(url: string): Promise<void>;
1812
+ goto(url: string, options?: {
1813
+ waitUntil?: 'load' | 'domcontentloaded' | 'networkidle';
1814
+ timeout?: number;
1815
+ }): Promise<void>;
1813
1816
  url(): string;
1814
1817
  locator(selector: string): TreeseedSceneLocator;
1815
1818
  getByTestId(testId: string): TreeseedSceneLocator;
@@ -58,6 +58,22 @@ function serviceHeaders(input) {
58
58
  "x-treeseed-service-secret": configuredValue(input, "TREESEED_API_WEB_SERVICE_SECRET") ?? configuredValue(input, "TREESEED_WEB_SERVICE_SECRET") ?? "treeseed-web-service-dev-secret"
59
59
  };
60
60
  }
61
+ async function sleep(ms) {
62
+ await new Promise((resolve) => setTimeout(resolve, ms));
63
+ }
64
+ function retryDelayMs(attempt) {
65
+ return Math.min(500 * attempt, 2e3);
66
+ }
67
+ function isRetryableStatus(status) {
68
+ return [408, 425, 429, 500, 502, 503, 504].includes(status);
69
+ }
70
+ async function gotoSceneFixturePage(page, url) {
71
+ await page.goto(url, { waitUntil: "domcontentloaded", timeout: 45e3 });
72
+ await page.waitForLoadState?.("networkidle", { timeout: 5e3 }).catch(() => void 0);
73
+ }
74
+ function isSignInUrl(value) {
75
+ return /\/auth\/sign-in/u.test(value);
76
+ }
61
77
  function seedActorsForRoles(roles) {
62
78
  const actors = {};
63
79
  for (const role of roles) {
@@ -76,32 +92,33 @@ function seedActorsForRoles(roles) {
76
92
  async function seedVisualAuditFixtures(input) {
77
93
  const actors = seedActorsForRoles(input.roles);
78
94
  if (Object.keys(actors).length === 0) return [];
79
- try {
80
- const response = await fetch(new URL("/v1/acceptance/seed", input.baseUrl).toString(), {
81
- method: "POST",
82
- headers: serviceHeaders(input),
83
- body: JSON.stringify({
84
- namespace: "visual-audit",
85
- password: TREESEED_VISUAL_AUDIT_PASSWORD,
86
- actors
87
- })
88
- });
89
- const text = await response.text();
90
- if (!response.ok) {
91
- return [sceneWarningDiagnostic(
92
- "scene.visual_audit_fixture_unavailable",
93
- `Visual audit API fixture seed failed with HTTP ${response.status}: ${text.slice(0, 500)}`,
94
- "roles"
95
- )];
95
+ let lastFailure = null;
96
+ for (let attempt = 1; attempt <= 3; attempt += 1) {
97
+ try {
98
+ const response = await fetch(new URL("/v1/acceptance/seed", input.baseUrl).toString(), {
99
+ method: "POST",
100
+ headers: serviceHeaders(input),
101
+ body: JSON.stringify({
102
+ namespace: "visual-audit",
103
+ password: TREESEED_VISUAL_AUDIT_PASSWORD,
104
+ actors
105
+ })
106
+ });
107
+ const text = await response.text();
108
+ if (response.ok) return [];
109
+ lastFailure = `HTTP ${response.status}: ${text.slice(0, 500)}`;
110
+ if (!isRetryableStatus(response.status) || attempt >= 3) break;
111
+ } catch (error) {
112
+ lastFailure = error instanceof Error ? error.message : String(error ?? "local fixture API is unavailable");
113
+ if (attempt >= 3) break;
96
114
  }
97
- return [];
98
- } catch (error) {
99
- return [sceneWarningDiagnostic(
100
- "scene.visual_audit_fixture_unavailable",
101
- `Visual audit API fixture seed failed against ${input.baseUrl}: ${error instanceof Error ? error.message : String(error ?? "local fixture API is unavailable")}.`,
102
- "roles"
103
- )];
115
+ await sleep(retryDelayMs(attempt));
104
116
  }
117
+ return [sceneWarningDiagnostic(
118
+ "scene.visual_audit_fixture_unavailable",
119
+ `Visual audit API fixture seed failed against ${input.baseUrl}: ${lastFailure ?? "local fixture API is unavailable"}.`,
120
+ "roles"
121
+ )];
105
122
  }
106
123
  async function ensureTreeseedSceneVisualAuditRoleFixtures(input) {
107
124
  const diagnostics = [];
@@ -165,40 +182,54 @@ async function signInTreeseedSceneVisualAuditRole(input) {
165
182
  return [sceneErrorDiagnostic("scene.visual_audit_role_unknown", `Visual audit role "${input.role}" has no fixture login.`, "role")];
166
183
  }
167
184
  const apiBaseUrl = input.apiBaseUrl?.trim() || input.baseUrl;
185
+ let lastError = null;
168
186
  try {
169
- const session = await clientFor(apiBaseUrl).webSignIn({ login: user.email, password: user.password });
170
- const accessToken = session.payload.accessToken;
171
- if (accessToken) {
172
- const webUrl = new URL(input.baseUrl);
173
- await input.page.context().addCookies([{
174
- name: "ts_market_api_access",
175
- value: accessToken,
176
- domain: webUrl.hostname,
177
- path: "/",
178
- httpOnly: true,
179
- secure: webUrl.protocol === "https:",
180
- sameSite: "Lax",
181
- expires: Math.floor(Date.now() / 1e3) + Number(session.payload.expiresInSeconds ?? 900)
182
- }]);
183
- await input.page.goto(new URL("/app/", input.baseUrl).toString(), { waitUntil: "networkidle", timeout: 2e4 });
184
- if (!/\/auth\/sign-in/u.test(input.page.url())) return [];
187
+ for (let attempt = 1; attempt <= 3; attempt += 1) {
188
+ const session = await clientFor(apiBaseUrl).webSignIn({ login: user.email, password: user.password });
189
+ const accessToken = session.payload.accessToken;
190
+ if (accessToken) {
191
+ const webUrl = new URL(input.baseUrl);
192
+ await input.page.context().addCookies([{
193
+ name: "ts_market_api_access",
194
+ value: accessToken,
195
+ domain: webUrl.hostname,
196
+ path: "/",
197
+ httpOnly: true,
198
+ secure: webUrl.protocol === "https:",
199
+ sameSite: "Lax",
200
+ expires: Math.floor(Date.now() / 1e3) + Number(session.payload.expiresInSeconds ?? 900)
201
+ }]);
202
+ await gotoSceneFixturePage(input.page, new URL("/app/", input.baseUrl).toString());
203
+ if (!isSignInUrl(input.page.url())) return [];
204
+ }
205
+ await sleep(retryDelayMs(attempt));
185
206
  }
186
207
  } catch {
187
208
  }
188
- try {
189
- await input.page.goto(new URL("/auth/sign-in", input.baseUrl).toString(), { waitUntil: "networkidle", timeout: 2e4 });
190
- await input.page.locator('input[name="login"], input[name="email"], input[name="emailOrUsername"], input[name="username"]').first().fill(user.email, { timeout: 5e3 });
191
- await input.page.locator('input[name="password"]').first().fill(user.password, { timeout: 5e3 });
192
- await input.page.getByRole("button", { name: /sign in/i }).click({ timeout: 5e3 });
193
- await input.page.waitForLoadState("networkidle", { timeout: 5e3 }).catch(() => void 0);
194
- const url = input.page.url();
195
- if (/\/auth\/sign-in/u.test(url)) {
196
- return [sceneWarningDiagnostic("scene.visual_audit_role_login_failed", `Visual audit login for ${input.role} did not leave the sign-in page. Ensure deterministic fixture user ${user.email} exists.`, "roles")];
209
+ for (let attempt = 1; attempt <= 3; attempt += 1) {
210
+ try {
211
+ const signInUrl = new URL("/auth/sign-in", input.baseUrl);
212
+ signInUrl.searchParams.set("returnTo", "/app/");
213
+ await gotoSceneFixturePage(input.page, signInUrl.toString());
214
+ await input.page.locator('input[name="login"], input[name="email"], input[name="emailOrUsername"], input[name="username"]').first().fill(user.email, { timeout: 1e4 });
215
+ await input.page.locator('input[name="password"]').first().fill(user.password, { timeout: 1e4 });
216
+ await input.page.getByRole("button", { name: /sign in/i }).click({ timeout: 1e4 });
217
+ await input.page.waitForURL?.((url) => !isSignInUrl(url.pathname), { timeout: 15e3 }).catch(() => void 0);
218
+ await input.page.waitForLoadState?.("networkidle", { timeout: 5e3 }).catch(() => void 0);
219
+ if (!isSignInUrl(input.page.url())) {
220
+ return [];
221
+ }
222
+ lastError = new Error(`Visual audit login for ${input.role} did not leave the sign-in page. Ensure deterministic fixture user ${user.email} exists.`);
223
+ } catch (error) {
224
+ lastError = error;
197
225
  }
198
- return [];
199
- } catch (error) {
200
- return [sceneWarningDiagnostic("scene.visual_audit_role_login_failed", error instanceof Error ? error.message : String(error ?? `Visual audit login for ${input.role} failed.`), "roles")];
226
+ await sleep(retryDelayMs(attempt));
201
227
  }
228
+ return [sceneWarningDiagnostic(
229
+ "scene.visual_audit_role_login_failed",
230
+ lastError instanceof Error ? lastError.message : String(lastError ?? `Visual audit login for ${input.role} failed.`),
231
+ "roles"
232
+ )];
202
233
  }
203
234
  export {
204
235
  TREESEED_VISUAL_AUDIT_PASSWORD,
@@ -1048,9 +1048,11 @@ export declare function workflowRelease(helpers: WorkflowOperationHelpers, input
1048
1048
  rootInstall: {
1049
1049
  status: string;
1050
1050
  reason: string;
1051
+ attempts?: undefined;
1051
1052
  } | {
1052
1053
  status: string;
1053
1054
  reason: null;
1055
+ attempts: number;
1054
1056
  };
1055
1057
  commit: {
1056
1058
  committed: boolean;
@@ -1933,31 +1933,36 @@ function runReleaseNpmInstall(repoDir, options = {}) {
1933
1933
  }
1934
1934
  const args = repoDir === options.workspaceRoot ? ["install", "--package-lock-only", "--ignore-scripts", "--no-audit", "--no-fund"] : ["install", "--package-lock-only", "--ignore-scripts", "--workspaces=false", "--no-audit", "--no-fund"];
1935
1935
  const spawnCommand = npmCommandForWorkflowSpawn(args);
1936
- const result = spawnSync(spawnCommand.command, spawnCommand.args, {
1937
- cwd: repoDir,
1938
- env: {
1939
- ...process.env,
1940
- npm_config_audit: "false",
1941
- npm_config_fetch_retries: "2",
1942
- npm_config_fund: "false",
1943
- npm_config_foreground_scripts: "true",
1944
- npm_config_loglevel: "warn",
1945
- npm_config_maxsockets: "4",
1946
- npm_config_prefer_offline: "true",
1947
- npm_config_progress: "false"
1948
- },
1949
- stdio: "pipe",
1950
- encoding: "utf8"
1951
- });
1952
- if (result.status !== 0) {
1953
- const detail = [
1936
+ let lastDetail = "";
1937
+ for (let attempt = 1; attempt <= 10; attempt += 1) {
1938
+ const result = spawnSync(spawnCommand.command, spawnCommand.args, {
1939
+ cwd: repoDir,
1940
+ env: {
1941
+ ...process.env,
1942
+ npm_config_audit: "false",
1943
+ npm_config_fetch_retries: "4",
1944
+ npm_config_fund: "false",
1945
+ npm_config_foreground_scripts: "true",
1946
+ npm_config_loglevel: "warn",
1947
+ npm_config_maxsockets: "4",
1948
+ npm_config_prefer_online: "true",
1949
+ npm_config_progress: "false"
1950
+ },
1951
+ stdio: "pipe",
1952
+ encoding: "utf8"
1953
+ });
1954
+ if (result.status === 0) {
1955
+ return { status: "completed", reason: null, attempts: attempt };
1956
+ }
1957
+ lastDetail = [
1954
1958
  result.error?.message,
1955
1959
  result.stderr?.trim(),
1956
1960
  result.stdout?.trim()
1957
1961
  ].filter(Boolean).join("\n");
1958
- throw new Error(detail || `npm ${args.join(" ")} failed`);
1962
+ if (!/No matching version found|notarget|ETARGET|E404/u.test(lastDetail) || attempt === 10) break;
1963
+ spawnSync("sleep", ["30"], { stdio: "ignore" });
1959
1964
  }
1960
- return { status: "completed", reason: null };
1965
+ throw new Error(lastDetail || `npm ${args.join(" ")} failed`);
1961
1966
  }
1962
1967
  function pathIsWithin(parent, candidate) {
1963
1968
  const path = relative(parent, candidate);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@treeseed/sdk",
3
- "version": "0.12.7",
3
+ "version": "0.12.9",
4
4
  "description": "Shared Treeseed SDK for content-backed and D1-backed object models.",
5
5
  "license": "AGPL-3.0-only",
6
6
  "repository": {