@storyshelf/core 0.1.0 → 0.1.3

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (183) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +9 -8
  3. package/dist/adapter/auth.mjs.map +1 -1
  4. package/dist/adapter/capture-runner.d.mts +4 -0
  5. package/dist/adapter/git-host/helpers.d.mts +1 -0
  6. package/dist/adapter/git-host/helpers.mjs +2 -1
  7. package/dist/adapter/git-host/helpers.mjs.map +1 -1
  8. package/dist/adapter/storage.d.mts +1 -0
  9. package/dist/adapters/capture-queue.d.mts +2 -0
  10. package/dist/adapters/webhook-events.mjs +8 -0
  11. package/dist/adapters/webhook-events.mjs.map +1 -1
  12. package/dist/capture/adapter.d.mts +11 -1
  13. package/dist/capture/adapter.mjs +19 -1
  14. package/dist/capture/adapter.mjs.map +1 -1
  15. package/dist/capture/orchestrator.d.mts +7 -0
  16. package/dist/capture/orchestrator.mjs +43 -10
  17. package/dist/capture/orchestrator.mjs.map +1 -1
  18. package/dist/capture/pipeline.d.mts +5 -3
  19. package/dist/capture/pipeline.mjs +18 -6
  20. package/dist/capture/pipeline.mjs.map +1 -1
  21. package/dist/capture/queue.d.mts +2 -0
  22. package/dist/capture/queue.mjs +2 -1
  23. package/dist/capture/queue.mjs.map +1 -1
  24. package/dist/capture/status-fanout.mjs +1 -0
  25. package/dist/capture/status-fanout.mjs.map +1 -1
  26. package/dist/capture/storybook.d.mts +1 -0
  27. package/dist/capture/storybook.mjs +12 -3
  28. package/dist/capture/storybook.mjs.map +1 -1
  29. package/dist/config.d.mts +27 -20
  30. package/dist/config.mjs +16 -2
  31. package/dist/config.mjs.map +1 -1
  32. package/dist/ddl.d.mts +2 -1
  33. package/dist/ddl.mjs +3 -0
  34. package/dist/ddl.mjs.map +1 -1
  35. package/dist/diff/engine.d.mts +8 -0
  36. package/dist/diff/engine.mjs +10 -2
  37. package/dist/diff/engine.mjs.map +1 -1
  38. package/dist/index.d.mts +7 -4
  39. package/dist/index.mjs +10 -3
  40. package/dist/index.mjs.map +1 -1
  41. package/dist/logger.d.mts +3 -3
  42. package/dist/logger.mjs.map +1 -1
  43. package/dist/middleware/csrf.mjs +2 -1
  44. package/dist/middleware/csrf.mjs.map +1 -1
  45. package/dist/middleware/rate-limit.mjs +1 -0
  46. package/dist/middleware/rate-limit.mjs.map +1 -1
  47. package/dist/models/baseline.d.mts +1 -0
  48. package/dist/models/baseline.mjs +3 -1
  49. package/dist/models/baseline.mjs.map +1 -1
  50. package/dist/models/build.d.mts +19 -1
  51. package/dist/models/build.mjs +22 -2
  52. package/dist/models/build.mjs.map +1 -1
  53. package/dist/models/comment.d.mts +2 -0
  54. package/dist/models/comment.mjs +3 -1
  55. package/dist/models/comment.mjs.map +1 -1
  56. package/dist/models/label.d.mts +6 -0
  57. package/dist/models/label.mjs +13 -1
  58. package/dist/models/label.mjs.map +1 -1
  59. package/dist/models/member.mjs +2 -1
  60. package/dist/models/member.mjs.map +1 -1
  61. package/dist/models/project.d.mts +1 -0
  62. package/dist/models/project.mjs +3 -2
  63. package/dist/models/project.mjs.map +1 -1
  64. package/dist/models/snapshot.d.mts +1 -0
  65. package/dist/models/snapshot.mjs +2 -1
  66. package/dist/models/snapshot.mjs.map +1 -1
  67. package/dist/models/status-config.mjs +2 -1
  68. package/dist/models/status-config.mjs.map +1 -1
  69. package/dist/models/token.mjs +2 -1
  70. package/dist/models/token.mjs.map +1 -1
  71. package/dist/models/webhook.d.mts +1 -0
  72. package/dist/models/webhook.mjs +2 -1
  73. package/dist/models/webhook.mjs.map +1 -1
  74. package/dist/pages/build-detail.mjs +2 -1
  75. package/dist/pages/build-detail.mjs.map +1 -1
  76. package/dist/pages/build-diff.mjs +1 -0
  77. package/dist/pages/build-diff.mjs.map +1 -1
  78. package/dist/pages/compute-jobs.mjs +2 -0
  79. package/dist/pages/compute-jobs.mjs.map +1 -1
  80. package/dist/pages/label-detail.mjs +227 -0
  81. package/dist/pages/label-detail.mjs.map +1 -0
  82. package/dist/pages/login.mjs +1 -0
  83. package/dist/pages/login.mjs.map +1 -1
  84. package/dist/pages/project-builds.mjs +1 -0
  85. package/dist/pages/project-builds.mjs.map +1 -1
  86. package/dist/pages/project-create.mjs +1 -0
  87. package/dist/pages/project-create.mjs.map +1 -1
  88. package/dist/pages/project-settings.mjs +33 -42
  89. package/dist/pages/project-settings.mjs.map +1 -1
  90. package/dist/pages/projects.mjs +1 -0
  91. package/dist/pages/projects.mjs.map +1 -1
  92. package/dist/pages/root.mjs +1 -0
  93. package/dist/pages/root.mjs.map +1 -1
  94. package/dist/pages/settings-general.mjs +1 -0
  95. package/dist/pages/settings-general.mjs.map +1 -1
  96. package/dist/pages/settings-labels.mjs +1 -0
  97. package/dist/pages/settings-labels.mjs.map +1 -1
  98. package/dist/pages/settings-members.mjs +1 -0
  99. package/dist/pages/settings-members.mjs.map +1 -1
  100. package/dist/pages/settings-status.mjs +1 -0
  101. package/dist/pages/settings-status.mjs.map +1 -1
  102. package/dist/pages/settings-tests.mjs +149 -0
  103. package/dist/pages/settings-tests.mjs.map +1 -0
  104. package/dist/pages/settings-tokens.mjs +1 -0
  105. package/dist/pages/settings-tokens.mjs.map +1 -1
  106. package/dist/pages/settings-webhooks.mjs +1 -0
  107. package/dist/pages/settings-webhooks.mjs.map +1 -1
  108. package/dist/pages/storybook.mjs +59 -0
  109. package/dist/pages/storybook.mjs.map +1 -0
  110. package/dist/retention/purge.d.mts +3 -0
  111. package/dist/retention/purge.mjs +2 -1
  112. package/dist/retention/purge.mjs.map +1 -1
  113. package/dist/routers/admin.mjs +1 -0
  114. package/dist/routers/admin.mjs.map +1 -1
  115. package/dist/routers/assets.mjs +1 -0
  116. package/dist/routers/assets.mjs.map +1 -1
  117. package/dist/routers/auth.mjs +1 -0
  118. package/dist/routers/auth.mjs.map +1 -1
  119. package/dist/routers/builds.handlers.mjs +15 -3
  120. package/dist/routers/builds.handlers.mjs.map +1 -1
  121. package/dist/routers/builds.mjs +5 -2
  122. package/dist/routers/builds.mjs.map +1 -1
  123. package/dist/routers/comments.mjs +1 -0
  124. package/dist/routers/comments.mjs.map +1 -1
  125. package/dist/routers/helpers.mjs +7 -1
  126. package/dist/routers/helpers.mjs.map +1 -1
  127. package/dist/routers/htmx.mjs +2 -0
  128. package/dist/routers/htmx.mjs.map +1 -1
  129. package/dist/routers/labels.mjs +34 -1
  130. package/dist/routers/labels.mjs.map +1 -1
  131. package/dist/routers/media.mjs +1 -0
  132. package/dist/routers/media.mjs.map +1 -1
  133. package/dist/routers/members.mjs +1 -0
  134. package/dist/routers/members.mjs.map +1 -1
  135. package/dist/routers/projects.mjs +1 -0
  136. package/dist/routers/projects.mjs.map +1 -1
  137. package/dist/routers/schemas.mjs +40 -4
  138. package/dist/routers/schemas.mjs.map +1 -1
  139. package/dist/routers/settings.mjs +22 -0
  140. package/dist/routers/settings.mjs.map +1 -1
  141. package/dist/routers/snapshots.mjs +1 -0
  142. package/dist/routers/snapshots.mjs.map +1 -1
  143. package/dist/routers/status-configs.mjs +1 -0
  144. package/dist/routers/status-configs.mjs.map +1 -1
  145. package/dist/routers/storybook.mjs +113 -0
  146. package/dist/routers/storybook.mjs.map +1 -0
  147. package/dist/routers/tokens.mjs +1 -0
  148. package/dist/routers/tokens.mjs.map +1 -1
  149. package/dist/routers/ui.mjs +25 -2
  150. package/dist/routers/ui.mjs.map +1 -1
  151. package/dist/routers/webhooks.mjs +1 -0
  152. package/dist/routers/webhooks.mjs.map +1 -1
  153. package/dist/schema-tables.mjs +166 -0
  154. package/dist/schema-tables.mjs.map +1 -0
  155. package/dist/schema.d.mts +194 -4352
  156. package/dist/schema.mjs +26 -136
  157. package/dist/schema.mjs.map +1 -1
  158. package/dist/store.mjs +2 -0
  159. package/dist/store.mjs.map +1 -1
  160. package/dist/types.d.mts +14 -1
  161. package/dist/types.mjs +8 -0
  162. package/dist/types.mjs.map +1 -1
  163. package/dist/ui/components.mjs +5 -0
  164. package/dist/ui/components.mjs.map +1 -1
  165. package/dist/ui/document.d.mts +1 -0
  166. package/dist/ui/document.mjs +10 -0
  167. package/dist/ui/document.mjs.map +1 -1
  168. package/dist/ui/styles.mjs +1 -0
  169. package/dist/ui/styles.mjs.map +1 -1
  170. package/dist/ui/theme.mjs +2 -0
  171. package/dist/ui/theme.mjs.map +1 -1
  172. package/dist/urls.d.mts +8 -0
  173. package/dist/urls.mjs +7 -0
  174. package/dist/urls.mjs.map +1 -1
  175. package/dist/utils/hash.mjs +3 -0
  176. package/dist/utils/hash.mjs.map +1 -1
  177. package/dist/utils/paths.d.mts +5 -0
  178. package/dist/utils/paths.mjs +5 -0
  179. package/dist/utils/paths.mjs.map +1 -1
  180. package/dist/utils/ulid.d.mts +2 -0
  181. package/dist/utils/ulid.mjs +5 -3
  182. package/dist/utils/ulid.mjs.map +1 -1
  183. package/package.json +76 -67
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Siddhant Gupta
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md CHANGED
@@ -22,10 +22,10 @@ import { createShelfRouter } from "@storyshelf/core";
22
22
  const app = createShelfRouter({
23
23
  database, // DatabaseAdapter
24
24
  storage, // StorageAdapter
25
- capture, // CaptureRunner (optional)
26
- auth, // AuthAdapter (optional)
27
- status, // StatusAdapter (optional)
28
- logger, // pino Logger (optional; built internally if omitted)
25
+ captureRunner, // CaptureRunner (optional)
26
+ auth, // AuthAdapter (optional)
27
+ gitHosts, // GitHostProvider[] (optional)
28
+ logger, // pino Logger (optional; built internally if omitted)
29
29
  ui: { name: "My Shelf" }, // UIConfig (optional)
30
30
  config: { captureConcurrency: 2, purgeTtlDays: 30 }, // ShelfConfig (optional)
31
31
  });
@@ -44,9 +44,10 @@ Assembles the router from the provided adapters. `ShelfOptions`:
44
44
  | ------ | ---- | ----------- |
45
45
  | `database` | `DatabaseAdapter` | **Required.** Data access. |
46
46
  | `storage` | `StorageAdapter` | **Required.** Blob storage for screenshots, diffs, storybook archives. |
47
- | `capture` | `CaptureRunner` | Optional. Enables the async capture queue. |
47
+ | `captureRunner` | `CaptureRunner` | Optional. Enables the async capture pipeline (pure renderer). |
48
+ | `captureQueue` | `CaptureQueue` | Optional. Queue adapter; defaults to `InMemoryCaptureQueue`. |
48
49
  | `auth` | `AuthAdapter` | Optional. Enables auth and the login UI. |
49
- | `status` | `StatusAdapter` | Optional. Reports CI status checks. |
50
+ | `gitHosts` | `GitHostProvider[]` | Optional. Git-host adapters (GitHub/GitLab) for status checks, merge gates, PR comments. |
50
51
  | `logger` | `Logger` (pino) | Optional. Shared logger. Construct a fallback via `createShelfLogger()`. |
51
52
  | `ui` | `UIConfig` | Optional. UI branding. |
52
53
  | `config` | `ShelfConfig` | Optional. Server behavior. |
@@ -83,7 +84,7 @@ All adapters are constructor-injected (no AsyncLocalStorage). See `docs/architec
83
84
  - `StorageAdapter` — `read`, `write`, `delete`, `exists`, `list(prefix)`.
84
85
  - `AuthAdapter` — `check(request)`, `createSession(user)`, `destroySession(sessionId)`, optional `handleCallback(callback)`. Also exports `AuthUser`, `AuthCallback`, and the shared `SESSION_COOKIE`.
85
86
  - `CaptureRunner` — a **pure capture renderer**: `render(input) => RenderResult`, `cancel(buildId)`. Also exports `JobStatus`, `RenderedSnapshot`, `RenderResult`.
86
- - `StatusAdapter` — `setStatus(context, gitSha, status, url)`. Also exports `CheckStatus`.
87
+ - `GitHostProvider` / `GitHostAdapter` — set commit status checks, detect merges, and upsert PR comments. Real providers ship in `@storyshelf/git-github` and `@storyshelf/git-gitlab`. Also exports `CheckStatus`.
87
88
 
88
89
  ### Logging
89
90
 
@@ -113,7 +114,7 @@ See `docs/architecture.md` and the ADRs in `docs/adr/`.
113
114
 
114
115
  ## Deployment targets
115
116
 
116
- The core router is runtime-agnostic (Web `Request`/`Response`, `fetch`, `crypto`, `URL`). The only Node-specific code in the stack is `@hono/node-server` in `@storyshelf/node-server`. You can assemble on any platform:
117
+ The core router is runtime-agnostic (Web `Request`/`Response`, `fetch`, `crypto`, `URL`). The only Node-specific piece is the in-process `InMemoryCaptureQueue`, which suits long-lived Node servers; serverless runtimes swap in a remote `CaptureQueue` (e.g. `@storyshelf/queue-sqs`) plus a separate worker. You assemble a server for any platform — `storyshelf server init` generates a scaffold with the adapters you choose:
117
118
 
118
119
  | Platform | Database | Storage | Capture queue | Server entry |
119
120
  |----------|----------|---------|---------------|--------------|
@@ -1 +1 @@
1
- {"version":3,"file":"auth.mjs","names":[],"sources":["../../src/adapters/auth.ts"],"sourcesContent":["import type { AdapterMetadata } from \"./metadata.ts\";\nimport type { ProjectRole, SiteRole } from \"../types.ts\";\n\n/** Shared session cookie name used by all auth adapters (ADR 0008). */\nexport const SESSION_COOKIE = \"storyshelf_session\";\n\n/** An authenticated user of the platform. */\nexport interface AuthUser {\n /** User ID. */\n id: string;\n /** User email. */\n email: string;\n /** Display name. */\n name: string;\n /** Optional avatar URL. */\n avatarUrl?: string;\n /** Site-wide role. */\n role: SiteRole;\n}\n\n/** Data passed to an auth adapter when an OAuth callback is received. */\nexport interface AuthCallback {\n /** Provider name. */\n provider: string;\n /** Authorization code from the provider. */\n code: string;\n /** Anti-CSRF state value. */\n state: string;\n}\n\n/** Access controls granted to a user for a specific project. */\nexport interface ProjectAccess {\n /** Project role. */\n role: ProjectRole;\n}\n\n/** Pluggable authentication abstraction (ADR 0008). */\nexport interface AuthAdapter {\n /** Adapter identity. */\n readonly metadata?: AdapterMetadata;\n /** Resolve the current user from a request, or null if unauthenticated. */\n check(request: Request): Promise<AuthUser | null>;\n /** Create a session for a user and return a session token. */\n createSession(user: AuthUser): Promise<string>;\n /** Destroy a session by its id. */\n destroySession(sessionId: string): Promise<void>;\n /** Handle an OAuth callback and return the resolved user, if supported. */\n handleCallback?(callback: AuthCallback): Promise<AuthUser | null>;\n}\n"],"mappings":";;;;;AAIA,MAAa,iBAAiB"}
1
+ {"version":3,"file":"auth.mjs","names":[],"sources":["../../src/adapters/auth.ts"],"sourcesContent":["/**\n * Auth adapter interface: authenticate users and resolve project access.\n */\nimport type { AdapterMetadata } from \"./metadata.ts\";\nimport type { ProjectRole, SiteRole } from \"../types.ts\";\n\n/** Shared session cookie name used by all auth adapters (ADR 0008). */\nexport const SESSION_COOKIE = \"storyshelf_session\";\n\n/** An authenticated user of the platform. */\nexport interface AuthUser {\n /** User ID. */\n id: string;\n /** User email. */\n email: string;\n /** Display name. */\n name: string;\n /** Optional avatar URL. */\n avatarUrl?: string;\n /** Site-wide role. */\n role: SiteRole;\n}\n\n/** Data passed to an auth adapter when an OAuth callback is received. */\nexport interface AuthCallback {\n /** Provider name. */\n provider: string;\n /** Authorization code from the provider. */\n code: string;\n /** Anti-CSRF state value. */\n state: string;\n}\n\n/** Access controls granted to a user for a specific project. */\nexport interface ProjectAccess {\n /** Project role. */\n role: ProjectRole;\n}\n\n/** Pluggable authentication abstraction (ADR 0008). */\nexport interface AuthAdapter {\n /** Adapter identity. */\n readonly metadata?: AdapterMetadata;\n /** Resolve the current user from a request, or null if unauthenticated. */\n check(request: Request): Promise<AuthUser | null>;\n /** Create a session for a user and return a session token. */\n createSession(user: AuthUser): Promise<string>;\n /** Destroy a session by its id. */\n destroySession(sessionId: string): Promise<void>;\n /** Handle an OAuth callback and return the resolved user, if supported. */\n handleCallback?(callback: AuthCallback): Promise<AuthUser | null>;\n}\n"],"mappings":";;;;;AAOA,MAAa,iBAAiB"}
@@ -61,6 +61,10 @@ interface CaptureRunner {
61
61
  viewports: Viewport[];
62
62
  /** Optional logger for render-time diagnostics. */
63
63
  logger?: Logger;
64
+ /** Whether to execute Storybook play functions before screenshot. */
65
+ executePlay?: boolean;
66
+ /** Timeout for play function execution in ms. */
67
+ playTimeoutMs?: number;
64
68
  }): Promise<RenderResult>;
65
69
  /** Cancel a pending or in-flight render for a build. */
66
70
  cancel(buildId: string): Promise<void>;
@@ -6,6 +6,7 @@ import { CheckStatus } from "../git-host.mjs";
6
6
  declare function describeStatus(status: CheckStatus): string;
7
7
  /** Markdown comment body helpers — single comment per build, idempotent via marker. */
8
8
  declare function commentMarker(url: string): string;
9
+ /** Build the Markdown body for a StoryShelf status comment. */
9
10
  declare function buildCommentMarkdown(status: CheckStatus, url: string, context: string): string;
10
11
  //#endregion
11
12
  export { buildCommentMarkdown, commentMarker, describeStatus };
@@ -15,12 +15,13 @@ function describeStatus(status) {
15
15
  function commentMarker(url) {
16
16
  return `<!-- storyshelf:${url} -->`;
17
17
  }
18
+ /** Build the Markdown body for a StoryShelf status comment. */
18
19
  function buildCommentMarkdown(status, url, context) {
19
20
  switch (status) {
20
21
  case "pending": return `Visual tests pending for \`${context}\` — [View build](${url})`;
21
22
  case "success": return `Visual tests passed for \`${context}\` — [View build](${url})`;
22
23
  case "failure": return `Visual changes detected for \`${context}\` — [View build](${url})`;
23
- default: return `Visual tests \`${status}\` for \`${context}\` — [View build](${url})`;
24
+ default: return `Visual tests for \`${context}\` — [View build](${url})`;
24
25
  }
25
26
  }
26
27
  //#endregion
@@ -1 +1 @@
1
- {"version":3,"file":"helpers.mjs","names":[],"sources":["../../../src/adapters/git-host/helpers.ts"],"sourcesContent":["import type { CheckStatus } from \"./index.ts\";\n\n/** Human description for a check status — shared across GitHub/GitLab. */\nexport function describeStatus(status: CheckStatus): string {\n switch (status) {\n case \"pending\": {\n return \"Visual tests pending\";\n }\n case \"success\": {\n return \"Visual tests passed\";\n }\n case \"failure\": {\n return \"Visual changes detected or tests failed\";\n }\n default: {\n return \"Visual changes detected or tests failed\";\n }\n }\n}\n\n/** Markdown comment body helpers — single comment per build, idempotent via marker. */\nexport function commentMarker(url: string): string {\n return `<!-- storyshelf:${url} -->`;\n}\n\nexport function buildCommentMarkdown(status: CheckStatus, url: string, context: string): string {\n switch (status) {\n case \"pending\": {\n return `Visual tests pending for \\`${context}\\` — [View build](${url})`;\n }\n case \"success\": {\n return `Visual tests passed for \\`${context}\\` — [View build](${url})`;\n }\n case \"failure\": {\n return `Visual changes detected for \\`${context}\\` — [View build](${url})`;\n }\n default: {\n // eslint-disable-next-line restrict-template-expressions -- status is never in default case\n return `Visual tests \\`${status}\\` for \\`${context}\\` — [View build](${url})`;\n }\n }\n}\n"],"mappings":";;;;;AAGA,SAAgB,eAAe,QAA6B;CAC1D,QAAQ,QAAR;EACE,KAAK,WACH,OAAO;EAET,KAAK,WACH,OAAO;EAET,KAAK,WACH,OAAO;EAET,SACE,OAAO;CAEX;AACF;;AAGA,SAAgB,cAAc,KAAqB;CACjD,OAAO,mBAAmB,IAAI;AAChC;AAEA,SAAgB,qBAAqB,QAAqB,KAAa,SAAyB;CAC9F,QAAQ,QAAR;EACE,KAAK,WACH,OAAO,8BAA8B,QAAQ,oBAAoB,IAAI;EAEvE,KAAK,WACH,OAAO,6BAA6B,QAAQ,oBAAoB,IAAI;EAEtE,KAAK,WACH,OAAO,iCAAiC,QAAQ,oBAAoB,IAAI;EAE1E,SAEE,OAAO,kBAAkB,OAAO,WAAW,QAAQ,oBAAoB,IAAI;CAE/E;AACF"}
1
+ {"version":3,"file":"helpers.mjs","names":[],"sources":["../../../src/adapters/git-host/helpers.ts"],"sourcesContent":["import type { CheckStatus } from \"./index.ts\";\n\n/** Human description for a check status — shared across GitHub/GitLab. */\nexport function describeStatus(status: CheckStatus): string {\n switch (status) {\n case \"pending\": {\n return \"Visual tests pending\";\n }\n case \"success\": {\n return \"Visual tests passed\";\n }\n case \"failure\": {\n return \"Visual changes detected or tests failed\";\n }\n default: {\n return \"Visual changes detected or tests failed\";\n }\n }\n}\n\n/** Markdown comment body helpers — single comment per build, idempotent via marker. */\nexport function commentMarker(url: string): string {\n return `<!-- storyshelf:${url} -->`;\n}\n\n/** Build the Markdown body for a StoryShelf status comment. */\nexport function buildCommentMarkdown(status: CheckStatus, url: string, context: string): string {\n switch (status) {\n case \"pending\": {\n return `Visual tests pending for \\`${context}\\` — [View build](${url})`;\n }\n case \"success\": {\n return `Visual tests passed for \\`${context}\\` — [View build](${url})`;\n }\n case \"failure\": {\n return `Visual changes detected for \\`${context}\\` — [View build](${url})`;\n }\n default: {\n return `Visual tests for \\`${context}\\` — [View build](${url})`;\n }\n }\n}\n"],"mappings":";;;;;AAGA,SAAgB,eAAe,QAA6B;CAC1D,QAAQ,QAAR;EACE,KAAK,WACH,OAAO;EAET,KAAK,WACH,OAAO;EAET,KAAK,WACH,OAAO;EAET,SACE,OAAO;CAEX;AACF;;AAGA,SAAgB,cAAc,KAAqB;CACjD,OAAO,mBAAmB,IAAI;AAChC;;AAGA,SAAgB,qBAAqB,QAAqB,KAAa,SAAyB;CAC9F,QAAQ,QAAR;EACE,KAAK,WACH,OAAO,8BAA8B,QAAQ,oBAAoB,IAAI;EAEvE,KAAK,WACH,OAAO,6BAA6B,QAAQ,oBAAoB,IAAI;EAEtE,KAAK,WACH,OAAO,iCAAiC,QAAQ,oBAAoB,IAAI;EAE1E,SACE,OAAO,sBAAsB,QAAQ,oBAAoB,IAAI;CAEjE;AACF"}
@@ -1,6 +1,7 @@
1
1
  import __tsdown_shims_path from 'node:path';
2
2
  import __tsdown_shims_url from 'node:url';
3
3
  import { AdapterMetadata } from "../adapters/metadata.mjs";
4
+ import { Buffer } from "node:buffer";
4
5
  //#region src/adapters/storage.d.ts
5
6
  /** Blob storage abstraction for screenshots, diffs and Storybook archives. */
6
7
  interface StorageAdapter {
@@ -2,7 +2,9 @@ import __tsdown_shims_path from 'node:path';
2
2
  import __tsdown_shims_url from 'node:url';
3
3
  import { AdapterMetadata } from "./metadata.mjs";
4
4
  //#region src/adapters/capture-queue.d.ts
5
+ /** Lifecycle status of a queued capture job. */
5
6
  type JobStatus = "queued" | "running" | "completed" | "failed" | "cancelled";
7
+ /** Observable state of a single capture queue entry. */
6
8
  interface QueueEntry {
7
9
  buildId: string;
8
10
  status: JobStatus;
@@ -17,6 +17,14 @@ async function sendWebhook(url, secret, event) {
17
17
  body
18
18
  });
19
19
  }
20
+ /**
21
+ * Deliver an event to every subscribed webhook of a project.
22
+ *
23
+ * @param db - Database adapter for loading subscriptions.
24
+ * @param projectId - Project whose webhooks receive the event.
25
+ * @param event - Event name (e.g. "baseline:created").
26
+ * @param data - Event payload.
27
+ */
20
28
  async function emitWebhookEvent(db, projectId, event, data) {
21
29
  const webhooks = await new WebhookModel(db).list(projectId);
22
30
  const eventPayload = {
@@ -1 +1 @@
1
- {"version":3,"file":"webhook-events.mjs","names":[],"sources":["../../src/adapters/webhook-events.ts"],"sourcesContent":["import type { DatabaseAdapter } from \"./database.ts\";\nimport { WebhookModel } from \"../models/webhook.ts\";\nimport { hmacSha256 } from \"../utils/hash.ts\";\n\nexport interface WebhookEvent {\n event: string;\n projectId: string;\n data: Record<string, unknown>;\n timestamp: string;\n}\n\nasync function sendWebhook(url: string, secret: string, event: WebhookEvent): Promise<void> {\n const body = JSON.stringify(event);\n const signature = hmacSha256(secret, body);\n await fetch(url, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n \"X-StoryShelf-Event\": event.event,\n \"X-StoryShelf-Signature\": signature,\n },\n body,\n });\n}\n\nexport async function emitWebhookEvent(\n db: DatabaseAdapter,\n projectId: string,\n event: string,\n data: Record<string, unknown>,\n): Promise<void> {\n const webhookModel = new WebhookModel(db);\n const webhooks = await webhookModel.list(projectId);\n const eventPayload: WebhookEvent = {\n event,\n projectId,\n data,\n timestamp: new Date().toISOString(),\n };\n\n await Promise.allSettled(\n webhooks.map(async (webhook) => {\n const events = WebhookModel.eventsOf(webhook);\n if (events.length > 0 && !events.includes(event)) {\n return;\n }\n try {\n await sendWebhook(webhook.url, webhook.secret, eventPayload);\n } catch {\n // webhook delivery failures are non-fatal\n }\n }),\n );\n}\n"],"mappings":";;;;;;AAWA,eAAe,YAAY,KAAa,QAAgB,OAAoC;CAC1F,MAAM,OAAO,KAAK,UAAU,KAAK;CACjC,MAAM,YAAY,WAAW,QAAQ,IAAI;CACzC,MAAM,MAAM,KAAK;EACf,QAAQ;EACR,SAAS;GACP,gBAAgB;GAChB,sBAAsB,MAAM;GAC5B,0BAA0B;EAC5B;EACA;CACF,CAAC;AACH;AAEA,eAAsB,iBACpB,IACA,WACA,OACA,MACe;CAEf,MAAM,WAAW,MAAM,IADE,aAAa,EACJ,CAAC,CAAC,KAAK,SAAS;CAClD,MAAM,eAA6B;EACjC;EACA;EACA;EACA,4BAAW,IAAI,KAAK,EAAA,CAAE,YAAY;CACpC;CAEA,MAAM,QAAQ,WACZ,SAAS,IAAI,OAAO,YAAY;EAC9B,MAAM,SAAS,aAAa,SAAS,OAAO;EAC5C,IAAI,OAAO,SAAS,KAAK,CAAC,OAAO,SAAS,KAAK,GAC7C;EAEF,IAAI;GACF,MAAM,YAAY,QAAQ,KAAK,QAAQ,QAAQ,YAAY;EAC7D,QAAQ,CAER;CACF,CAAC,CACH;AACF"}
1
+ {"version":3,"file":"webhook-events.mjs","names":[],"sources":["../../src/adapters/webhook-events.ts"],"sourcesContent":["import type { DatabaseAdapter } from \"./database.ts\";\nimport { WebhookModel } from \"../models/webhook.ts\";\nimport { hmacSha256 } from \"../utils/hash.ts\";\n\n/** Outbound webhook payload delivered to subscribers. */\nexport interface WebhookEvent {\n event: string;\n projectId: string;\n data: Record<string, unknown>;\n timestamp: string;\n}\n\nasync function sendWebhook(url: string, secret: string, event: WebhookEvent): Promise<void> {\n const body = JSON.stringify(event);\n const signature = hmacSha256(secret, body);\n await fetch(url, {\n method: \"POST\",\n headers: {\n \"Content-Type\": \"application/json\",\n \"X-StoryShelf-Event\": event.event,\n \"X-StoryShelf-Signature\": signature,\n },\n body,\n });\n}\n\n/**\n * Deliver an event to every subscribed webhook of a project.\n *\n * @param db - Database adapter for loading subscriptions.\n * @param projectId - Project whose webhooks receive the event.\n * @param event - Event name (e.g. \"baseline:created\").\n * @param data - Event payload.\n */\nexport async function emitWebhookEvent(\n db: DatabaseAdapter,\n projectId: string,\n event: string,\n data: Record<string, unknown>,\n): Promise<void> {\n const webhookModel = new WebhookModel(db);\n const webhooks = await webhookModel.list(projectId);\n const eventPayload: WebhookEvent = {\n event,\n projectId,\n data,\n timestamp: new Date().toISOString(),\n };\n\n await Promise.allSettled(\n webhooks.map(async (webhook) => {\n const events = WebhookModel.eventsOf(webhook);\n if (events.length > 0 && !events.includes(event)) {\n return;\n }\n try {\n await sendWebhook(webhook.url, webhook.secret, eventPayload);\n } catch {\n // Webhook delivery failures are non-fatal\n }\n }),\n );\n}\n"],"mappings":";;;;;;AAYA,eAAe,YAAY,KAAa,QAAgB,OAAoC;CAC1F,MAAM,OAAO,KAAK,UAAU,KAAK;CACjC,MAAM,YAAY,WAAW,QAAQ,IAAI;CACzC,MAAM,MAAM,KAAK;EACf,QAAQ;EACR,SAAS;GACP,gBAAgB;GAChB,sBAAsB,MAAM;GAC5B,0BAA0B;EAC5B;EACA;CACF,CAAC;AACH;;;;;;;;;AAUA,eAAsB,iBACpB,IACA,WACA,OACA,MACe;CAEf,MAAM,WAAW,MAAM,IADE,aAAa,EACJ,CAAC,CAAC,KAAK,SAAS;CAClD,MAAM,eAA6B;EACjC;EACA;EACA;EACA,4BAAW,IAAI,KAAK,EAAA,CAAE,YAAY;CACpC;CAEA,MAAM,QAAQ,WACZ,SAAS,IAAI,OAAO,YAAY;EAC9B,MAAM,SAAS,aAAa,SAAS,OAAO;EAC5C,IAAI,OAAO,SAAS,KAAK,CAAC,OAAO,SAAS,KAAK,GAC7C;EAEF,IAAI;GACF,MAAM,YAAY,QAAQ,KAAK,QAAQ,QAAQ,YAAY;EAC7D,QAAQ,CAER;CACF,CAAC,CACH;AACF"}
@@ -1,6 +1,14 @@
1
1
  import __tsdown_shims_path from 'node:path';
2
2
  import __tsdown_shims_url from 'node:url';
3
3
  //#region src/capture/adapter.d.ts
4
+ /** Per-story capture parameters from the Storybook index. */
5
+ interface StoryParameters {
6
+ disableSnapshot?: boolean;
7
+ delay?: number;
8
+ diffThreshold?: number;
9
+ pauseAnimationAtEnd?: boolean;
10
+ flakyTest?: boolean;
11
+ }
4
12
  /** A single discoverable story or docs entry within a Storybook. */
5
13
  interface StoryEntry {
6
14
  id: string;
@@ -9,6 +17,7 @@ interface StoryEntry {
9
17
  importPath?: string;
10
18
  tags?: string[];
11
19
  type: "story" | "docs";
20
+ parameters?: StoryParameters;
12
21
  }
13
22
  /** Adapter that discovers and renders stories from a Storybook build. */
14
23
  interface StorySourceAdapter {
@@ -23,7 +32,8 @@ interface Viewport {
23
32
  width: number;
24
33
  height: number;
25
34
  }
35
+ /** Default viewport used when none is configured. */
26
36
  declare const DEFAULT_VIEWPORTS: Viewport[];
27
37
  //#endregion
28
- export { DEFAULT_VIEWPORTS, StoryEntry, StorySourceAdapter, Viewport };
38
+ export { DEFAULT_VIEWPORTS, StoryEntry, StoryParameters, StorySourceAdapter, Viewport };
29
39
  //# sourceMappingURL=adapter.d.mts.map
@@ -2,12 +2,30 @@ import "node:path";
2
2
  import "node:url";
3
3
  import.meta.url;
4
4
  //#region src/capture/adapter.ts
5
+ /** Return whether a story is marked flaky (failures stay non-blocking). */
6
+ function isFlakyStory(entry) {
7
+ if (entry.parameters?.flakyTest) return true;
8
+ const tags = entry.tags ?? [];
9
+ for (const t of tags) if (t.toLowerCase() === "flaky-test") return true;
10
+ return false;
11
+ }
12
+ /** Return whether a story is excluded from snapshot capture. */
13
+ function isDisabledStory(entry) {
14
+ if (entry.parameters?.disableSnapshot) return true;
15
+ const tags = entry.tags ?? [];
16
+ for (const t of tags) {
17
+ const lower = t.toLowerCase();
18
+ if (lower === "skip" || lower === "disable" || lower === "disable-snapshot") return true;
19
+ }
20
+ return false;
21
+ }
22
+ /** Default viewport used when none is configured. */
5
23
  const DEFAULT_VIEWPORTS = [{
6
24
  name: "desktop",
7
25
  width: 1280,
8
26
  height: 720
9
27
  }];
10
28
  //#endregion
11
- export { DEFAULT_VIEWPORTS };
29
+ export { DEFAULT_VIEWPORTS, isDisabledStory, isFlakyStory };
12
30
 
13
31
  //# sourceMappingURL=adapter.mjs.map
@@ -1 +1 @@
1
- {"version":3,"file":"adapter.mjs","names":[],"sources":["../../src/capture/adapter.ts"],"sourcesContent":["/** A single discoverable story or docs entry within a Storybook. */\nexport interface StoryEntry {\n id: string;\n title: string;\n name: string;\n importPath?: string;\n tags?: string[];\n type: \"story\" | \"docs\";\n}\n\n/** Adapter that discovers and renders stories from a Storybook build. */\nexport interface StorySourceAdapter {\n name: string;\n discover(source: string): Promise<StoryEntry[]>;\n buildUrl(baseUrl: string, storyId: string): string;\n screenshotSelector?: string;\n}\n\n/** A viewport size at which stories are captured. */\nexport interface Viewport {\n name: string;\n width: number;\n height: number;\n}\n\nexport const DEFAULT_VIEWPORTS: Viewport[] = [{ name: \"desktop\", width: 1280, height: 720 }];\n"],"mappings":";;;;AAyBA,MAAa,oBAAgC,CAAC;CAAE,MAAM;CAAW,OAAO;CAAM,QAAQ;AAAI,CAAC"}
1
+ {"version":3,"file":"adapter.mjs","names":[],"sources":["../../src/capture/adapter.ts"],"sourcesContent":["/** Per-story capture parameters from the Storybook index. */\nexport interface StoryParameters {\n disableSnapshot?: boolean;\n delay?: number;\n diffThreshold?: number;\n pauseAnimationAtEnd?: boolean;\n flakyTest?: boolean;\n}\n\n/** Return whether a story is marked flaky (failures stay non-blocking). */\nexport function isFlakyStory(entry: Pick<StoryEntry, \"tags\" | \"parameters\">): boolean {\n if (entry.parameters?.flakyTest) return true;\n const tags = entry.tags ?? [];\n for (const t of tags) {\n if (t.toLowerCase() === \"flaky-test\") return true;\n }\n return false;\n}\n\n/** Return whether a story is excluded from snapshot capture. */\nexport function isDisabledStory(entry: Pick<StoryEntry, \"tags\" | \"parameters\">): boolean {\n if (entry.parameters?.disableSnapshot) return true;\n const tags = entry.tags ?? [];\n for (const t of tags) {\n const lower = t.toLowerCase();\n if (lower === \"skip\" || lower === \"disable\" || lower === \"disable-snapshot\") return true;\n }\n return false;\n}\n\n/** A single discoverable story or docs entry within a Storybook. */\nexport interface StoryEntry {\n id: string;\n title: string;\n name: string;\n importPath?: string;\n tags?: string[];\n type: \"story\" | \"docs\";\n parameters?: StoryParameters;\n}\n\n/** Adapter that discovers and renders stories from a Storybook build. */\nexport interface StorySourceAdapter {\n name: string;\n discover(source: string): Promise<StoryEntry[]>;\n buildUrl(baseUrl: string, storyId: string): string;\n screenshotSelector?: string;\n}\n\n/** A viewport size at which stories are captured. */\nexport interface Viewport {\n name: string;\n width: number;\n height: number;\n}\n\n/** Default viewport used when none is configured. */\nexport const DEFAULT_VIEWPORTS: Viewport[] = [{ name: \"desktop\", width: 1280, height: 720 }];\n"],"mappings":";;;;;AAUA,SAAgB,aAAa,OAAyD;CACpF,IAAI,MAAM,YAAY,WAAW,OAAO;CACxC,MAAM,OAAO,MAAM,QAAQ,CAAC;CAC5B,KAAK,MAAM,KAAK,MACd,IAAI,EAAE,YAAY,MAAM,cAAc,OAAO;CAE/C,OAAO;AACT;;AAGA,SAAgB,gBAAgB,OAAyD;CACvF,IAAI,MAAM,YAAY,iBAAiB,OAAO;CAC9C,MAAM,OAAO,MAAM,QAAQ,CAAC;CAC5B,KAAK,MAAM,KAAK,MAAM;EACpB,MAAM,QAAQ,EAAE,YAAY;EAC5B,IAAI,UAAU,UAAU,UAAU,aAAa,UAAU,oBAAoB,OAAO;CACtF;CACA,OAAO;AACT;;AA6BA,MAAa,oBAAgC,CAAC;CAAE,MAAM;CAAW,OAAO;CAAM,QAAQ;AAAI,CAAC"}
@@ -6,6 +6,7 @@ import { DatabaseAdapter } from "../adapter/database.mjs";
6
6
  import { StorageAdapter } from "../adapter/storage.mjs";
7
7
  import { Logger } from "pino";
8
8
  //#region src/capture/orchestrator.d.ts
9
+ /** Inputs for running a capture job against a Storybook build. */
9
10
  interface CaptureJobOptions {
10
11
  db: DatabaseAdapter;
11
12
  storage: StorageAdapter;
@@ -14,6 +15,12 @@ interface CaptureJobOptions {
14
15
  viewports?: Viewport[];
15
16
  logger?: Logger;
16
17
  }
18
+ /**
19
+ * Run the full capture for a build: extract, render, persist, and finalize.
20
+ *
21
+ * @param input - Build id plus the originating request id.
22
+ * @param options - Adapters, scratch dir, viewports, and logger.
23
+ */
17
24
  declare function executeCaptureJob(input: {
18
25
  buildId: string;
19
26
  reqId?: string;
@@ -3,14 +3,20 @@ import "node:url";
3
3
  import.meta.url;
4
4
  import { BuildModel } from "../models/build.mjs";
5
5
  import { ProjectModel } from "../models/project.mjs";
6
- import { storybookZipPath } from "../utils/paths.mjs";
6
+ import { storybookDir, storybookZipPath } from "../utils/paths.mjs";
7
7
  import { persistCapture } from "./pipeline.mjs";
8
8
  import { StorybookAdapter } from "./storybook.mjs";
9
- import { DEFAULT_VIEWPORTS } from "./adapter.mjs";
10
- import { mkdir, rm } from "node:fs/promises";
11
- import { join, resolve, sep } from "node:path";
9
+ import { DEFAULT_VIEWPORTS, isDisabledStory, isFlakyStory } from "./adapter.mjs";
10
+ import { mkdir, readFile, readdir, rm } from "node:fs/promises";
11
+ import { join, relative, resolve, sep } from "node:path";
12
12
  import AdmZip from "adm-zip";
13
13
  //#region src/capture/orchestrator.ts
14
+ /**
15
+ * Run the full capture for a build: extract, render, persist, and finalize.
16
+ *
17
+ * @param input - Build id plus the originating request id.
18
+ * @param options - Adapters, scratch dir, viewports, and logger.
19
+ */
14
20
  async function executeCaptureJob(input, options) {
15
21
  const builds = new BuildModel(options.db);
16
22
  const { build, project } = await loadTarget(options, input.buildId);
@@ -20,27 +26,37 @@ async function executeCaptureJob(input, options) {
20
26
  });
21
27
  await builds.setStatus(build.id, "capturing");
22
28
  const startTime = performance.now();
23
- let storybookDir;
29
+ let extractedDir;
24
30
  try {
25
31
  const extractStart = performance.now();
26
- storybookDir = await extractStorybook(options, project.id, build.id);
32
+ extractedDir = await extractStorybook(options, project.id, build.id);
27
33
  const extractDuration = performance.now() - extractStart;
28
34
  logger?.info({ durationMs: Math.round(extractDuration) }, "storybook extracted");
29
- const stories = await new StorybookAdapter().discover(storybookDir);
35
+ const staticsStart = performance.now();
36
+ await persistStorybookStatics(options.storage, extractedDir, project.id, build.id);
37
+ logger?.info({ durationMs: Math.round(performance.now() - staticsStart) }, "storybook statics persisted");
38
+ const stories = (await new StorybookAdapter().discover(extractedDir)).filter((s) => !isDisabledStory(s));
30
39
  const viewports = options.viewports ?? DEFAULT_VIEWPORTS;
31
40
  const renderStart = performance.now();
32
41
  const result = await options.runner.render({
33
42
  buildId: build.id,
34
- storybookDir,
43
+ storybookDir: extractedDir,
35
44
  stories,
36
45
  viewports,
37
- logger
46
+ logger,
47
+ executePlay: project.executePlay ?? false,
48
+ playTimeoutMs: project.playTimeoutMs ?? 1e4
38
49
  });
39
50
  const renderDuration = performance.now() - renderStart;
40
51
  logger?.info({
41
52
  durationMs: Math.round(renderDuration),
42
53
  storyCount: stories.length
43
54
  }, "stories rendered");
55
+ const flakyStoryIds = new Set(stories.filter((s) => isFlakyStory(s)).map((s) => s.id));
56
+ const blockingFailed = /* @__PURE__ */ new Set();
57
+ const flakyFailed = /* @__PURE__ */ new Set();
58
+ for (const f of result.failures) if (flakyStoryIds.has(f.storyId)) flakyFailed.add(f.storyId);
59
+ else blockingFailed.add(f.storyId);
44
60
  const persistStart = performance.now();
45
61
  await persistCapture({
46
62
  db: options.db,
@@ -50,7 +66,7 @@ async function executeCaptureJob(input, options) {
50
66
  viewports,
51
67
  captures: result.captures,
52
68
  logger
53
- }, new Set(result.failures.map((failure) => failure.storyId)));
69
+ }, blockingFailed, flakyFailed);
54
70
  const persistDuration = performance.now() - persistStart;
55
71
  logger?.info({ durationMs: Math.round(persistDuration) }, "capture persisted");
56
72
  const totalDuration = performance.now() - startTime;
@@ -97,6 +113,23 @@ async function extractStorybook(options, projectId, buildId) {
97
113
  zip.extractAllTo(targetDir, true);
98
114
  return targetDir;
99
115
  }
116
+ async function walkFiles(dir) {
117
+ const entries = await readdir(dir, { withFileTypes: true });
118
+ return (await Promise.all(entries.map(async (entry) => {
119
+ const full = join(dir, entry.name);
120
+ if (entry.isDirectory()) return await walkFiles(full);
121
+ return [full];
122
+ }))).flat();
123
+ }
124
+ async function persistStorybookStatics(storage, sourceDir, projectId, buildId) {
125
+ const root = resolve(sourceDir);
126
+ const destinationPrefix = storybookDir(projectId, buildId);
127
+ const files = await walkFiles(root);
128
+ await Promise.all(files.map(async (file) => {
129
+ const rel = relative(root, file);
130
+ await storage.write(`${destinationPrefix}/${rel}`, await readFile(file));
131
+ }));
132
+ }
100
133
  //#endregion
101
134
  export { executeCaptureJob };
102
135
 
@@ -1 +1 @@
1
- {"version":3,"file":"orchestrator.mjs","names":[],"sources":["../../src/capture/orchestrator.ts"],"sourcesContent":["import { mkdir, rm } from \"node:fs/promises\";\nimport { join, resolve, sep } from \"node:path\";\n\nimport AdmZip from \"adm-zip\";\nimport type { Logger } from \"pino\";\n\nimport type { CaptureRunner } from \"../adapters/capture-runner.ts\";\nimport type { DatabaseAdapter } from \"../adapters/database.ts\";\nimport type { StorageAdapter } from \"../adapters/storage.ts\";\nimport { BuildModel } from \"../models/build.ts\";\nimport { ProjectModel } from \"../models/project.ts\";\nimport type { Build, Project } from \"../schema.ts\";\nimport { storybookZipPath } from \"../utils/paths.ts\";\nimport { persistCapture } from \"./pipeline.ts\";\nimport { StorybookAdapter } from \"./storybook.ts\";\nimport { DEFAULT_VIEWPORTS } from \"./adapter.ts\";\nimport type { Viewport } from \"./adapter.ts\";\n\nexport interface CaptureJobOptions {\n db: DatabaseAdapter;\n storage: StorageAdapter;\n runner: CaptureRunner;\n scratchDir: string;\n viewports?: Viewport[];\n logger?: Logger;\n}\nexport async function executeCaptureJob(input: { buildId: string; reqId?: string }, options: CaptureJobOptions): Promise<void> {\n const builds = new BuildModel(options.db);\n const { build, project } = await loadTarget(options, input.buildId);\n const logger = options.logger?.child({ buildId: input.buildId, reqId: input.reqId });\n await builds.setStatus(build.id, \"capturing\");\n\n const startTime = performance.now();\n let storybookDir: string | undefined;\n try {\n const extractStart = performance.now();\n storybookDir = await extractStorybook(options, project.id, build.id);\n const extractDuration = performance.now() - extractStart;\n logger?.info({ durationMs: Math.round(extractDuration) }, \"storybook extracted\");\n\n const adapter = new StorybookAdapter();\n const stories = await adapter.discover(storybookDir);\n const viewports = options.viewports ?? DEFAULT_VIEWPORTS;\n\n const renderStart = performance.now();\n const result = await options.runner.render({ buildId: build.id, storybookDir, stories, viewports, logger });\n const renderDuration = performance.now() - renderStart;\n logger?.info({ durationMs: Math.round(renderDuration), storyCount: stories.length }, \"stories rendered\");\n\n const persistStart = performance.now();\n await persistCapture(\n {\n db: options.db,\n storage: options.storage,\n project,\n build,\n viewports,\n captures: result.captures,\n logger,\n },\n new Set(result.failures.map((failure) => failure.storyId)),\n );\n const persistDuration = performance.now() - persistStart;\n logger?.info({ durationMs: Math.round(persistDuration) }, \"capture persisted\");\n\n const totalDuration = performance.now() - startTime;\n logger?.info({ durationMs: Math.round(totalDuration) }, \"capture completed\");\n } catch (error) {\n const totalDuration = performance.now() - startTime;\n logger?.error({ durationMs: Math.round(totalDuration), err: error }, \"capture failed\");\n await builds.setStatus(build.id, \"failed\").catch((markError: unknown) => {\n logger?.error({ err: markError }, \"failed to mark build failed after capture error\");\n });\n throw error;\n }\n}\n\nasync function loadTarget(options: CaptureJobOptions, buildId: string): Promise<{ build: Build; project: Project }> {\n const build = await new BuildModel(options.db).get(buildId);\n if (!build) {\n throw new Error(`Build not found: ${buildId}`);\n }\n const project = await new ProjectModel(options.db).get(build.projectId);\n if (!project) {\n throw new Error(`Project not found: ${build.projectId}`);\n }\n return { build, project };\n}\n\nfunction blockedTarget(root: string, entryName: string): boolean {\n const candidate = resolve(join(root, entryName));\n return candidate !== root && !candidate.startsWith(root + sep);\n}\n\nfunction assertNoTraversal(zip: AdmZip, root: string): void {\n for (const entry of zip.getEntries()) {\n if (blockedTarget(root, entry.entryName)) {\n throw new Error(`Blocked path traversal in uploaded Storybook: ${entry.entryName}`);\n }\n }\n}\n\nasync function extractStorybook(options: CaptureJobOptions, projectId: string, buildId: string): Promise<string> {\n const targetDir = join(options.scratchDir, projectId, \"builds\", buildId, \"storybook\");\n const root = resolve(targetDir);\n const zip = new AdmZip(await options.storage.read(storybookZipPath(projectId, buildId)));\n assertNoTraversal(zip, root);\n await rm(targetDir, { recursive: true, force: true });\n await mkdir(targetDir, { recursive: true });\n zip.extractAllTo(targetDir, true);\n return targetDir;\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"orchestrator.mjs","names":[],"sources":["../../src/capture/orchestrator.ts"],"sourcesContent":["import { mkdir, readdir, readFile, rm } from \"node:fs/promises\";\nimport { join, relative, resolve, sep } from \"node:path\";\n\nimport AdmZip from \"adm-zip\";\nimport type { Logger } from \"pino\";\n\nimport type { CaptureRunner } from \"../adapters/capture-runner.ts\";\nimport type { DatabaseAdapter } from \"../adapters/database.ts\";\nimport type { StorageAdapter } from \"../adapters/storage.ts\";\nimport { BuildModel } from \"../models/build.ts\";\nimport { ProjectModel } from \"../models/project.ts\";\nimport type { Build, Project } from \"../schema.ts\";\nimport { storybookDir, storybookZipPath } from \"../utils/paths.ts\";\nimport { persistCapture } from \"./pipeline.ts\";\nimport { StorybookAdapter } from \"./storybook.ts\";\nimport { DEFAULT_VIEWPORTS, isDisabledStory, isFlakyStory } from \"./adapter.ts\";\nimport type { Viewport } from \"./adapter.ts\";\n\n/** Inputs for running a capture job against a Storybook build. */\nexport interface CaptureJobOptions {\n db: DatabaseAdapter;\n storage: StorageAdapter;\n runner: CaptureRunner;\n scratchDir: string;\n viewports?: Viewport[];\n logger?: Logger;\n}\n/**\n * Run the full capture for a build: extract, render, persist, and finalize.\n *\n * @param input - Build id plus the originating request id.\n * @param options - Adapters, scratch dir, viewports, and logger.\n */\nexport async function executeCaptureJob(input: { buildId: string; reqId?: string }, options: CaptureJobOptions): Promise<void> {\n const builds = new BuildModel(options.db);\n const { build, project } = await loadTarget(options, input.buildId);\n const logger = options.logger?.child({ buildId: input.buildId, reqId: input.reqId });\n await builds.setStatus(build.id, \"capturing\");\n\n const startTime = performance.now();\n let extractedDir: string | undefined;\n try {\n const extractStart = performance.now();\n extractedDir = await extractStorybook(options, project.id, build.id);\n const extractDuration = performance.now() - extractStart;\n logger?.info({ durationMs: Math.round(extractDuration) }, \"storybook extracted\");\n\n // Persist the extracted statics to storage so the published Storybook\n // (`storybookDir`) can be served after the scratch dir is cleaned up.\n const staticsStart = performance.now();\n await persistStorybookStatics(options.storage, extractedDir, project.id, build.id);\n logger?.info({ durationMs: Math.round(performance.now() - staticsStart) }, \"storybook statics persisted\");\n\n const adapter = new StorybookAdapter();\n const discovered = await adapter.discover(extractedDir);\n const stories = discovered.filter((s) => !isDisabledStory(s));\n const viewports = options.viewports ?? DEFAULT_VIEWPORTS;\n\n const renderStart = performance.now();\n const result = await options.runner.render({\n buildId: build.id,\n storybookDir: extractedDir,\n stories,\n viewports,\n logger,\n executePlay: project.executePlay ?? false,\n playTimeoutMs: project.playTimeoutMs ?? 10_000,\n });\n const renderDuration = performance.now() - renderStart;\n logger?.info({ durationMs: Math.round(renderDuration), storyCount: stories.length }, \"stories rendered\");\n\n const flakyStoryIds = new Set(stories.filter((s) => isFlakyStory(s)).map((s) => s.id));\n const blockingFailed = new Set<string>();\n const flakyFailed = new Set<string>();\n for (const f of result.failures) {\n if (flakyStoryIds.has(f.storyId)) flakyFailed.add(f.storyId);\n else blockingFailed.add(f.storyId);\n }\n\n const persistStart = performance.now();\n await persistCapture(\n {\n db: options.db,\n storage: options.storage,\n project,\n build,\n viewports,\n captures: result.captures,\n logger,\n },\n blockingFailed,\n flakyFailed,\n );\n const persistDuration = performance.now() - persistStart;\n logger?.info({ durationMs: Math.round(persistDuration) }, \"capture persisted\");\n\n const totalDuration = performance.now() - startTime;\n logger?.info({ durationMs: Math.round(totalDuration) }, \"capture completed\");\n } catch (error) {\n const totalDuration = performance.now() - startTime;\n logger?.error({ durationMs: Math.round(totalDuration), err: error }, \"capture failed\");\n await builds.setStatus(build.id, \"failed\").catch((markError: unknown) => {\n logger?.error({ err: markError }, \"failed to mark build failed after capture error\");\n });\n throw error;\n }\n}\n\nasync function loadTarget(options: CaptureJobOptions, buildId: string): Promise<{ build: Build; project: Project }> {\n const build = await new BuildModel(options.db).get(buildId);\n if (!build) {\n throw new Error(`Build not found: ${buildId}`);\n }\n const project = await new ProjectModel(options.db).get(build.projectId);\n if (!project) {\n throw new Error(`Project not found: ${build.projectId}`);\n }\n return { build, project };\n}\n\nfunction blockedTarget(root: string, entryName: string): boolean {\n const candidate = resolve(join(root, entryName));\n return candidate !== root && !candidate.startsWith(root + sep);\n}\n\nfunction assertNoTraversal(zip: AdmZip, root: string): void {\n for (const entry of zip.getEntries()) {\n if (blockedTarget(root, entry.entryName)) {\n throw new Error(`Blocked path traversal in uploaded Storybook: ${entry.entryName}`);\n }\n }\n}\n\nasync function extractStorybook(options: CaptureJobOptions, projectId: string, buildId: string): Promise<string> {\n const targetDir = join(options.scratchDir, projectId, \"builds\", buildId, \"storybook\");\n const root = resolve(targetDir);\n const zip = new AdmZip(await options.storage.read(storybookZipPath(projectId, buildId)));\n assertNoTraversal(zip, root);\n await rm(targetDir, { recursive: true, force: true });\n await mkdir(targetDir, { recursive: true });\n zip.extractAllTo(targetDir, true);\n return targetDir;\n}\n\nasync function walkFiles(dir: string): Promise<string[]> {\n const entries = await readdir(dir, { withFileTypes: true });\n const nested = await Promise.all(\n entries.map(async (entry) => {\n const full = join(dir, entry.name);\n if (entry.isDirectory()) {\n return await walkFiles(full);\n }\n return [full];\n }),\n );\n return nested.flat();\n}\n\nasync function persistStorybookStatics(storage: StorageAdapter, sourceDir: string, projectId: string, buildId: string): Promise<void> {\n const root = resolve(sourceDir);\n const destinationPrefix = storybookDir(projectId, buildId);\n const files = await walkFiles(root);\n await Promise.all(\n files.map(async (file) => {\n const rel = relative(root, file);\n await storage.write(`${destinationPrefix}/${rel}`, await readFile(file));\n }),\n );\n}\n"],"mappings":"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"}
@@ -32,10 +32,12 @@ interface CaptureContext {
32
32
  * storage writes and record keeping, so it can run against any renderer.
33
33
  *
34
34
  * @param ctx - Capture context.
35
- * @param renderFailedStoryIds - Story ids that the renderer could not capture.
36
- * They are excluded from the persisted set and force the build to `failed`.
35
+ * @param renderFailedStoryIds - Story ids that the renderer could not capture
36
+ * (blocking failures). They force the build to `failed`.
37
+ * @param flakyFailedStoryIds - Story ids that failed but are marked flaky
38
+ * (non-blocking). They are logged as warnings and do not block the build.
37
39
  */
38
- declare function persistCapture(ctx: CaptureContext, renderFailedStoryIds?: ReadonlySet<string>): Promise<void>;
40
+ declare function persistCapture(ctx: CaptureContext, renderFailedStoryIds?: ReadonlySet<string>, flakyFailedStoryIds?: ReadonlySet<string>): Promise<void>;
39
41
  //#endregion
40
42
  export { CaptureContext, persistCapture };
41
43
  //# sourceMappingURL=pipeline.d.mts.map
@@ -16,16 +16,26 @@ import { DEFAULT_DIFF_OPTIONS } from "../diff/options.mjs";
16
16
  * storage writes and record keeping, so it can run against any renderer.
17
17
  *
18
18
  * @param ctx - Capture context.
19
- * @param renderFailedStoryIds - Story ids that the renderer could not capture.
20
- * They are excluded from the persisted set and force the build to `failed`.
19
+ * @param renderFailedStoryIds - Story ids that the renderer could not capture
20
+ * (blocking failures). They force the build to `failed`.
21
+ * @param flakyFailedStoryIds - Story ids that failed but are marked flaky
22
+ * (non-blocking). They are logged as warnings and do not block the build.
21
23
  */
22
- async function persistCapture(ctx, renderFailedStoryIds = /* @__PURE__ */ new Set()) {
24
+ async function persistCapture(ctx, renderFailedStoryIds = /* @__PURE__ */ new Set(), flakyFailedStoryIds = /* @__PURE__ */ new Set()) {
23
25
  const failedStoryIds = new Set(renderFailedStoryIds);
26
+ const flakyIds = new Set(flakyFailedStoryIds);
24
27
  await Promise.all(ctx.captures.map(async (capture) => {
25
28
  try {
26
29
  await persistSnapshot(ctx, capture);
27
30
  } catch (error) {
28
- failedStoryIds.add(capture.story.id);
31
+ if (flakyIds.has(capture.story.id)) {
32
+ flakyIds.add(capture.story.id);
33
+ ctx.logger?.warn({
34
+ storyId: capture.story.id,
35
+ viewport: capture.viewportName,
36
+ err: error
37
+ }, "capture failed for flaky story (non-blocking)");
38
+ } else failedStoryIds.add(capture.story.id);
29
39
  ctx.logger?.error({
30
40
  storyId: capture.story.id,
31
41
  viewport: capture.viewportName,
@@ -33,7 +43,8 @@ async function persistCapture(ctx, renderFailedStoryIds = /* @__PURE__ */ new Se
33
43
  }, "capture failed for story");
34
44
  }
35
45
  }));
36
- await finalize(ctx, new Set(ctx.captures.map((c) => c.story.id)), failedStoryIds);
46
+ for (const id of flakyIds) if (!failedStoryIds.has(id)) ctx.logger?.warn({ storyId: id }, "flaky story failed (non-blocking)");
47
+ await finalize(ctx, new Set(ctx.captures.map((c) => c.story.id)), failedStoryIds, flakyIds);
37
48
  }
38
49
  function viewportByName(ctx, name) {
39
50
  return ctx.viewports.find((v) => v.name === name) ?? {
@@ -103,13 +114,14 @@ async function createWithBaseline(ctx, capture, viewport, screenshot, baselinePa
103
114
  diffPassed: result.passed
104
115
  });
105
116
  }
106
- async function finalize(ctx, storyIds, failedStoryIds) {
117
+ async function finalize(ctx, storyIds, failedStoryIds, flakyFailedStoryIds = /* @__PURE__ */ new Set()) {
107
118
  const builds = new BuildModel(ctx.db);
108
119
  const build = await builds.updateCounts(ctx.build.id);
109
120
  const hasCaptures = storyIds.size > 0;
110
121
  let status = "reviewing";
111
122
  if (failedStoryIds.size > 0) status = "failed";
112
123
  else if (hasCaptures && build.changedCount === 0) status = "approved";
124
+ if (flakyFailedStoryIds.size > 0 && failedStoryIds.size === 0) ctx.logger?.warn({ flakyStoryIds: [...flakyFailedStoryIds] }, "flaky stories failed (non-blocking)");
113
125
  await builds.setStatus(ctx.build.id, status);
114
126
  if (ctx.build.isDefault && hasCaptures) await new BaselineModel(ctx.db, ctx.storage).removeOrphans(ctx.project.id, new Set(storyIds));
115
127
  }
@@ -1 +1 @@
1
- {"version":3,"file":"pipeline.mjs","names":[],"sources":["../../src/capture/pipeline.ts"],"sourcesContent":["import type { Logger } from \"pino\";\n\nimport type { DatabaseAdapter } from \"../adapters/database.ts\";\nimport type { RenderedSnapshot } from \"../adapters/capture-runner.ts\";\nimport type { StorageAdapter } from \"../adapters/storage.ts\";\nimport { diffImages } from \"../diff/engine.ts\";\nimport { DEFAULT_DIFF_OPTIONS } from \"../diff/options.ts\";\nimport { BaselineModel } from \"../models/baseline.ts\";\nimport { BuildModel } from \"../models/build.ts\";\nimport { SnapshotModel } from \"../models/snapshot.ts\";\nimport type { Baseline, Build, Project } from \"../schema.ts\";\nimport type { BuildStatus } from \"../types.ts\";\nimport { diffPath, screenshotPath } from \"../utils/paths.ts\";\nimport type { Viewport } from \"./adapter.ts\";\n\n/** Persistence inputs for a completed capture run. */\nexport interface CaptureContext {\n /** Database adapter. */\n db: DatabaseAdapter;\n /** Storage adapter. */\n storage: StorageAdapter;\n /** The project being captured. */\n project: Project;\n /** The build being captured. */\n build: Build;\n /** Viewports at which stories were captured. */\n viewports: Viewport[];\n /** Screenshot buffers produced by the capture renderer. */\n captures: RenderedSnapshot[];\n /** Optional logger for capture diagnostics. */\n logger?: Logger;\n}\n\n/**\n * Persist a completed capture run: write screenshots, diff against the branch\n * baseline, create snapshots, and finalize the build.\n *\n * Rendering is performed by a pure `CaptureRunner`; this function owns only\n * storage writes and record keeping, so it can run against any renderer.\n *\n * @param ctx - Capture context.\n * @param renderFailedStoryIds - Story ids that the renderer could not capture.\n * They are excluded from the persisted set and force the build to `failed`.\n */\nexport async function persistCapture(ctx: CaptureContext, renderFailedStoryIds: ReadonlySet<string> = new Set()): Promise<void> {\n const failedStoryIds = new Set(renderFailedStoryIds);\n await Promise.all(\n ctx.captures.map(async (capture) => {\n try {\n await persistSnapshot(ctx, capture);\n } catch (error) {\n // A failure in one story/viewport must not abort the other persists.\n failedStoryIds.add(capture.story.id);\n ctx.logger?.error(\n { storyId: capture.story.id, viewport: capture.viewportName, err: error },\n \"capture failed for story\",\n );\n }\n }),\n );\n await finalize(ctx, new Set(ctx.captures.map((c) => c.story.id)), failedStoryIds);\n}\n\nfunction viewportByName(ctx: CaptureContext, name: string): Viewport {\n return ctx.viewports.find((v) => v.name === name) ?? { name, width: 0, height: 0 };\n}\n\nasync function persistSnapshot(ctx: CaptureContext, capture: RenderedSnapshot): Promise<void> {\n const viewport = viewportByName(ctx, capture.viewportName);\n const screenshot = screenshotPath(ctx.project.id, ctx.build.id, capture.story.id, capture.viewportName);\n await ctx.storage.write(screenshot, capture.screenshot);\n\n const baseline = await resolveBaseline(ctx, capture.story.id, capture.viewportName);\n if (!baseline) {\n await createWithoutBaseline(ctx, capture, viewport, screenshot);\n return;\n }\n await createWithBaseline(ctx, capture, viewport, screenshot, baseline.screenshotPath);\n}\n\nasync function resolveBaseline(ctx: CaptureContext, storyId: string, viewport: string): Promise<Baseline | null> {\n const baselines = new BaselineModel(ctx.db, ctx.storage);\n return await baselines.resolve(ctx.project.id, storyId, viewport, ctx.build.gitBranch, ctx.project.gitDefaultBranch);\n}\n\nasync function createWithoutBaseline(ctx: CaptureContext, capture: RenderedSnapshot, viewport: Viewport, screenshot: string): Promise<void> {\n const snapshots = new SnapshotModel(ctx.db);\n const status = ctx.build.isDefault ? \"approved\" : \"new\";\n const snapshot = await snapshots.create(ctx.project.id, ctx.build.id, {\n storyId: capture.story.id,\n storyName: capture.story.name,\n storyTitle: capture.story.title,\n storyImportPath: capture.story.importPath ?? \"\",\n viewportName: capture.viewportName,\n viewportWidth: viewport.width,\n viewportHeight: viewport.height,\n screenshotPath: screenshot,\n });\n await snapshots.setStatus(snapshot.id, status);\n\n if (ctx.build.isDefault) {\n const baselines = new BaselineModel(ctx.db, ctx.storage);\n await baselines.upsert(ctx.project.id, capture.story.id, capture.viewportName, ctx.build.gitBranch, snapshot.id, screenshot);\n }\n}\n\nasync function createWithBaseline(ctx: CaptureContext, capture: RenderedSnapshot, viewport: Viewport, screenshot: string, baselinePath: string): Promise<void> {\n const current = await ctx.storage.read(screenshot);\n const previous = await ctx.storage.read(baselinePath);\n const options = { ...DEFAULT_DIFF_OPTIONS, pixelThreshold: ctx.project.pixelThreshold, maxDiffRatio: ctx.project.maxDiffRatio };\n const result = diffImages(previous, current, options);\n\n const snapshots = new SnapshotModel(ctx.db);\n const snapshot = await snapshots.create(ctx.project.id, ctx.build.id, {\n storyId: capture.story.id,\n storyName: capture.story.name,\n storyTitle: capture.story.title,\n storyImportPath: capture.story.importPath ?? \"\",\n viewportName: capture.viewportName,\n viewportWidth: viewport.width,\n viewportHeight: viewport.height,\n screenshotPath: screenshot,\n });\n\n const status = result.passed ? \"unchanged\" : \"changed\";\n const diff = diffPath(ctx.project.id, ctx.build.id, capture.story.id, capture.viewportName);\n if (!result.passed && result.diffImage) {\n await ctx.storage.write(diff, result.diffImage);\n }\n await snapshots.update(snapshot.id, {\n status,\n diffPath: !result.passed && result.diffImage ? diff : null,\n diffPixels: result.diffPixels,\n diffRatio: result.diffRatio,\n diffPassed: result.passed,\n });\n}\n\nasync function finalize(ctx: CaptureContext, storyIds: ReadonlySet<string>, failedStoryIds: ReadonlySet<string>): Promise<void> {\n const builds = new BuildModel(ctx.db);\n const build = await builds.updateCounts(ctx.build.id);\n const hasCaptures = storyIds.size > 0;\n let status: BuildStatus = \"reviewing\";\n if (failedStoryIds.size > 0) {\n status = \"failed\";\n } else if (hasCaptures && build.changedCount === 0) {\n status = \"approved\";\n }\n await builds.setStatus(ctx.build.id, status);\n\n if (ctx.build.isDefault && hasCaptures) {\n const baselines = new BaselineModel(ctx.db, ctx.storage);\n await baselines.removeOrphans(ctx.project.id, new Set(storyIds));\n }\n}\n"],"mappings":"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"}
1
+ {"version":3,"file":"pipeline.mjs","names":[],"sources":["../../src/capture/pipeline.ts"],"sourcesContent":["import type { Logger } from \"pino\";\n\nimport type { DatabaseAdapter } from \"../adapters/database.ts\";\nimport type { RenderedSnapshot } from \"../adapters/capture-runner.ts\";\nimport type { StorageAdapter } from \"../adapters/storage.ts\";\nimport { diffImages } from \"../diff/engine.ts\";\nimport { DEFAULT_DIFF_OPTIONS } from \"../diff/options.ts\";\nimport { BaselineModel } from \"../models/baseline.ts\";\nimport { BuildModel } from \"../models/build.ts\";\nimport { SnapshotModel } from \"../models/snapshot.ts\";\nimport type { Baseline, Build, Project } from \"../schema.ts\";\nimport type { BuildStatus } from \"../types.ts\";\nimport { diffPath, screenshotPath } from \"../utils/paths.ts\";\nimport type { Viewport } from \"./adapter.ts\";\n\n/** Persistence inputs for a completed capture run. */\nexport interface CaptureContext {\n /** Database adapter. */\n db: DatabaseAdapter;\n /** Storage adapter. */\n storage: StorageAdapter;\n /** The project being captured. */\n project: Project;\n /** The build being captured. */\n build: Build;\n /** Viewports at which stories were captured. */\n viewports: Viewport[];\n /** Screenshot buffers produced by the capture renderer. */\n captures: RenderedSnapshot[];\n /** Optional logger for capture diagnostics. */\n logger?: Logger;\n}\n\n/**\n * Persist a completed capture run: write screenshots, diff against the branch\n * baseline, create snapshots, and finalize the build.\n *\n * Rendering is performed by a pure `CaptureRunner`; this function owns only\n * storage writes and record keeping, so it can run against any renderer.\n *\n * @param ctx - Capture context.\n * @param renderFailedStoryIds - Story ids that the renderer could not capture\n * (blocking failures). They force the build to `failed`.\n * @param flakyFailedStoryIds - Story ids that failed but are marked flaky\n * (non-blocking). They are logged as warnings and do not block the build.\n */\nexport async function persistCapture(\n ctx: CaptureContext,\n renderFailedStoryIds: ReadonlySet<string> = new Set(),\n flakyFailedStoryIds: ReadonlySet<string> = new Set(),\n): Promise<void> {\n const failedStoryIds = new Set(renderFailedStoryIds);\n const flakyIds = new Set(flakyFailedStoryIds);\n await Promise.all(\n ctx.captures.map(async (capture) => {\n try {\n await persistSnapshot(ctx, capture);\n } catch (error) {\n // A failure in one story/viewport must not abort the other persists.\n // Flaky stories remain non-blocking even on persist failure.\n if (flakyIds.has(capture.story.id)) {\n flakyIds.add(capture.story.id);\n ctx.logger?.warn(\n { storyId: capture.story.id, viewport: capture.viewportName, err: error },\n \"capture failed for flaky story (non-blocking)\",\n );\n } else {\n failedStoryIds.add(capture.story.id);\n }\n ctx.logger?.error(\n { storyId: capture.story.id, viewport: capture.viewportName, err: error },\n \"capture failed for story\",\n );\n }\n }),\n );\n // Also log flaky render failures as warnings\n for (const id of flakyIds) {\n if (!failedStoryIds.has(id)) {\n ctx.logger?.warn({ storyId: id }, \"flaky story failed (non-blocking)\");\n }\n }\n await finalize(ctx, new Set(ctx.captures.map((c) => c.story.id)), failedStoryIds, flakyIds);\n}\n\nfunction viewportByName(ctx: CaptureContext, name: string): Viewport {\n return ctx.viewports.find((v) => v.name === name) ?? { name, width: 0, height: 0 };\n}\n\nasync function persistSnapshot(ctx: CaptureContext, capture: RenderedSnapshot): Promise<void> {\n const viewport = viewportByName(ctx, capture.viewportName);\n const screenshot = screenshotPath(ctx.project.id, ctx.build.id, capture.story.id, capture.viewportName);\n await ctx.storage.write(screenshot, capture.screenshot);\n\n const baseline = await resolveBaseline(ctx, capture.story.id, capture.viewportName);\n if (!baseline) {\n await createWithoutBaseline(ctx, capture, viewport, screenshot);\n return;\n }\n await createWithBaseline(ctx, capture, viewport, screenshot, baseline.screenshotPath);\n}\n\nasync function resolveBaseline(ctx: CaptureContext, storyId: string, viewport: string): Promise<Baseline | null> {\n const baselines = new BaselineModel(ctx.db, ctx.storage);\n return await baselines.resolve(ctx.project.id, storyId, viewport, ctx.build.gitBranch, ctx.project.gitDefaultBranch);\n}\n\nasync function createWithoutBaseline(ctx: CaptureContext, capture: RenderedSnapshot, viewport: Viewport, screenshot: string): Promise<void> {\n const snapshots = new SnapshotModel(ctx.db);\n const status = ctx.build.isDefault ? \"approved\" : \"new\";\n const snapshot = await snapshots.create(ctx.project.id, ctx.build.id, {\n storyId: capture.story.id,\n storyName: capture.story.name,\n storyTitle: capture.story.title,\n storyImportPath: capture.story.importPath ?? \"\",\n viewportName: capture.viewportName,\n viewportWidth: viewport.width,\n viewportHeight: viewport.height,\n screenshotPath: screenshot,\n });\n await snapshots.setStatus(snapshot.id, status);\n\n if (ctx.build.isDefault) {\n const baselines = new BaselineModel(ctx.db, ctx.storage);\n await baselines.upsert(ctx.project.id, capture.story.id, capture.viewportName, ctx.build.gitBranch, snapshot.id, screenshot);\n }\n}\n\nasync function createWithBaseline(ctx: CaptureContext, capture: RenderedSnapshot, viewport: Viewport, screenshot: string, baselinePath: string): Promise<void> {\n const current = await ctx.storage.read(screenshot);\n const previous = await ctx.storage.read(baselinePath);\n const options = { ...DEFAULT_DIFF_OPTIONS, pixelThreshold: ctx.project.pixelThreshold, maxDiffRatio: ctx.project.maxDiffRatio };\n const result = diffImages(previous, current, options);\n\n const snapshots = new SnapshotModel(ctx.db);\n const snapshot = await snapshots.create(ctx.project.id, ctx.build.id, {\n storyId: capture.story.id,\n storyName: capture.story.name,\n storyTitle: capture.story.title,\n storyImportPath: capture.story.importPath ?? \"\",\n viewportName: capture.viewportName,\n viewportWidth: viewport.width,\n viewportHeight: viewport.height,\n screenshotPath: screenshot,\n });\n\n const status = result.passed ? \"unchanged\" : \"changed\";\n const diff = diffPath(ctx.project.id, ctx.build.id, capture.story.id, capture.viewportName);\n if (!result.passed && result.diffImage) {\n await ctx.storage.write(diff, result.diffImage);\n }\n await snapshots.update(snapshot.id, {\n status,\n diffPath: !result.passed && result.diffImage ? diff : null,\n diffPixels: result.diffPixels,\n diffRatio: result.diffRatio,\n diffPassed: result.passed,\n });\n}\n\nasync function finalize(\n ctx: CaptureContext,\n storyIds: ReadonlySet<string>,\n failedStoryIds: ReadonlySet<string>,\n flakyFailedStoryIds: ReadonlySet<string> = new Set(),\n): Promise<void> {\n const builds = new BuildModel(ctx.db);\n const build = await builds.updateCounts(ctx.build.id);\n const hasCaptures = storyIds.size > 0;\n let status: BuildStatus = \"reviewing\";\n if (failedStoryIds.size > 0) {\n status = \"failed\";\n } else if (hasCaptures && build.changedCount === 0) {\n status = \"approved\";\n }\n // Flaky failures do not block: log warning if any flaky stories failed\n if (flakyFailedStoryIds.size > 0 && failedStoryIds.size === 0) {\n ctx.logger?.warn({ flakyStoryIds: [...flakyFailedStoryIds] }, \"flaky stories failed (non-blocking)\");\n }\n await builds.setStatus(ctx.build.id, status);\n\n if (ctx.build.isDefault && hasCaptures) {\n const baselines = new BaselineModel(ctx.db, ctx.storage);\n await baselines.removeOrphans(ctx.project.id, new Set(storyIds));\n }\n}\n"],"mappings":"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"}
@@ -3,11 +3,13 @@ import __tsdown_shims_url from 'node:url';
3
3
  import { CaptureJob, CaptureQueue, QueueEntry } from "../adapters/capture-queue.mjs";
4
4
  import { Logger } from "pino";
5
5
  //#region src/capture/queue.d.ts
6
+ /** Options for the in-process capture queue. */
6
7
  interface InMemoryCaptureQueueOptions {
7
8
  concurrency: number;
8
9
  runJob: (job: CaptureJob) => Promise<void>;
9
10
  logger?: Logger;
10
11
  }
12
+ /** In-process capture queue with bounded concurrency. */
11
13
  declare class InMemoryCaptureQueue implements CaptureQueue {
12
14
  private readonly options;
13
15
  readonly metadata: {
@@ -2,11 +2,12 @@ import "node:path";
2
2
  import "node:url";
3
3
  import.meta.url;
4
4
  //#region src/capture/queue.ts
5
+ /** In-process capture queue with bounded concurrency. */
5
6
  var InMemoryCaptureQueue = class {
6
7
  options;
7
8
  metadata = {
8
9
  name: "In-Memory Queue",
9
- version: "0.1.0",
10
+ version: globalThis.__PKG_VERSION__ ?? "0.0.0",
10
11
  description: "In-process capture queue",
11
12
  kind: "memory"
12
13
  };
@@ -1 +1 @@
1
- {"version":3,"file":"queue.mjs","names":[],"sources":["../../src/capture/queue.ts"],"sourcesContent":["import type { Logger } from \"pino\";\n\nimport type { CaptureJob, CaptureQueue, QueueEntry } from \"../adapters/capture-queue.ts\";\n\ndeclare const __PKG_VERSION__: string;\n\nexport interface InMemoryCaptureQueueOptions {\n concurrency: number;\n runJob: (job: CaptureJob) => Promise<void>;\n logger?: Logger;\n}\nexport class InMemoryCaptureQueue implements CaptureQueue {\n readonly metadata = {\n name: \"In-Memory Queue\",\n version: typeof __PKG_VERSION__ === \"undefined\" ? \"0.0.0\" : __PKG_VERSION__, // oxlint-disable-line unicorn/no-typeof-undefined\n description: \"In-process capture queue\",\n kind: \"memory\",\n } as const;\n\n private readonly entries = new Map<string, QueueEntry>();\n private running = 0;\n private readonly waiting: (() => void)[] = [];\n private readonly inFlight = new Map<string, Promise<void>>();\n\n constructor(private readonly options: InMemoryCaptureQueueOptions) {}\n\n async enqueue(job: CaptureJob): Promise<void> {\n const entry = this.track(job.buildId);\n this.log(job)?.info(\"capture queued\");\n this.inFlight.set(job.buildId, this.process(job, entry));\n await Promise.resolve();\n }\n\n async status(buildId: string): Promise<QueueEntry | null> {\n return await Promise.resolve(this.entries.get(buildId) ?? null);\n }\n\n async active(): Promise<QueueEntry[]> {\n return await Promise.resolve([...this.entries.values()]\n .filter((entry) => entry.status === \"queued\" || entry.status === \"running\")\n .toSorted((a, b) => a.queuedAt.localeCompare(b.queuedAt)));\n }\n\n async recent(limit: number): Promise<QueueEntry[]> {\n return await Promise.resolve([...this.entries.values()].toSorted((a, b) => b.queuedAt.localeCompare(a.queuedAt)).slice(0, limit));\n }\n\n private async process(job: CaptureJob, entry: QueueEntry): Promise<void> {\n const log = this.log(job);\n await this.acquire();\n entry.status = \"running\";\n entry.startedAt = new Date().toISOString();\n log?.info(\"capture started\");\n try {\n await this.options.runJob(job);\n } catch (error) {\n entry.status = \"failed\";\n entry.finishedAt = new Date().toISOString();\n entry.error = error instanceof Error ? error.message : \"Capture failed\";\n log?.error({ err: error }, \"capture failed\");\n } finally {\n this.running -= 1;\n this.waiting.shift()?.();\n if (entry.status !== \"failed\") {\n entry.status = \"completed\";\n entry.finishedAt = new Date().toISOString();\n log?.info(\"capture completed\");\n }\n }\n }\n\n private log(job: CaptureJob): Logger | undefined {\n return this.options.logger?.child({ buildId: job.buildId, reqId: job.reqId });\n }\n\n private track(buildId: string): QueueEntry {\n const existing = this.entries.get(buildId);\n if (existing) {\n existing.status = \"queued\";\n existing.queuedAt = new Date().toISOString();\n existing.startedAt = undefined;\n existing.finishedAt = undefined;\n existing.error = undefined;\n return existing;\n }\n const entry: QueueEntry = { buildId, status: \"queued\", queuedAt: new Date().toISOString() };\n this.entries.set(buildId, entry);\n return entry;\n }\n\n private async acquire(): Promise<void> {\n if (this.running < this.options.concurrency) {\n this.running += 1;\n return;\n }\n await new Promise<void>((resolve) => {\n this.waiting.push(resolve);\n });\n await this.acquire();\n }\n}"],"mappings":";;;;AAWA,IAAa,uBAAb,MAA0D;CAa3B;CAZ7B,WAAoB;EAClB,MAAM;EACN,SAAA;EACA,aAAa;EACb,MAAM;CACR;CAEA,0BAA2B,IAAI,IAAwB;CACvD,UAAkB;CAClB,UAA2C,CAAC;CAC5C,2BAA4B,IAAI,IAA2B;CAE3D,YAAY,SAAuD;EAAtC,KAAA,UAAA;CAAuC;CAEpE,MAAM,QAAQ,KAAgC;EAC5C,MAAM,QAAQ,KAAK,MAAM,IAAI,OAAO;EACpC,KAAK,IAAI,GAAG,CAAC,EAAE,KAAK,gBAAgB;EACpC,KAAK,SAAS,IAAI,IAAI,SAAS,KAAK,QAAQ,KAAK,KAAK,CAAC;EACvD,MAAM,QAAQ,QAAQ;CACxB;CAEA,MAAM,OAAO,SAA6C;EACxD,OAAO,MAAM,QAAQ,QAAQ,KAAK,QAAQ,IAAI,OAAO,KAAK,IAAI;CAChE;CAEA,MAAM,SAAgC;EACpC,OAAO,MAAM,QAAQ,QAAQ,CAAC,GAAG,KAAK,QAAQ,OAAO,CAAC,CAAC,CACpD,QAAQ,UAAU,MAAM,WAAW,YAAY,MAAM,WAAW,SAAS,CAAC,CAC1E,UAAU,GAAG,MAAM,EAAE,SAAS,cAAc,EAAE,QAAQ,CAAC,CAAC;CAC7D;CAEA,MAAM,OAAO,OAAsC;EACjD,OAAO,MAAM,QAAQ,QAAQ,CAAC,GAAG,KAAK,QAAQ,OAAO,CAAC,CAAC,CAAC,UAAU,GAAG,MAAM,EAAE,SAAS,cAAc,EAAE,QAAQ,CAAC,CAAC,CAAC,MAAM,GAAG,KAAK,CAAC;CAClI;CAEA,MAAc,QAAQ,KAAiB,OAAkC;EACvE,MAAM,MAAM,KAAK,IAAI,GAAG;EACxB,MAAM,KAAK,QAAQ;EACnB,MAAM,SAAS;EACf,MAAM,6BAAY,IAAI,KAAK,EAAA,CAAE,YAAY;EACzC,KAAK,KAAK,iBAAiB;EAC3B,IAAI;GACF,MAAM,KAAK,QAAQ,OAAO,GAAG;EAC/B,SAAS,OAAO;GACd,MAAM,SAAS;GACf,MAAM,8BAAa,IAAI,KAAK,EAAA,CAAE,YAAY;GAC1C,MAAM,QAAQ,iBAAiB,QAAQ,MAAM,UAAU;GACvD,KAAK,MAAM,EAAE,KAAK,MAAM,GAAG,gBAAgB;EAC7C,UAAU;GACR,KAAK,WAAW;GAChB,KAAK,QAAQ,MAAM,CAAC,GAAG;GACvB,IAAI,MAAM,WAAW,UAAU;IAC7B,MAAM,SAAS;IACf,MAAM,8BAAa,IAAI,KAAK,EAAA,CAAE,YAAY;IAC1C,KAAK,KAAK,mBAAmB;GAC/B;EACF;CACF;CAEA,IAAY,KAAqC;EAC/C,OAAO,KAAK,QAAQ,QAAQ,MAAM;GAAE,SAAS,IAAI;GAAS,OAAO,IAAI;EAAM,CAAC;CAC9E;CAEA,MAAc,SAA6B;EACzC,MAAM,WAAW,KAAK,QAAQ,IAAI,OAAO;EACzC,IAAI,UAAU;GACZ,SAAS,SAAS;GAClB,SAAS,4BAAW,IAAI,KAAK,EAAA,CAAE,YAAY;GAC3C,SAAS,YAAY,KAAA;GACrB,SAAS,aAAa,KAAA;GACtB,SAAS,QAAQ,KAAA;GACjB,OAAO;EACT;EACA,MAAM,QAAoB;GAAE;GAAS,QAAQ;GAAU,2BAAU,IAAI,KAAK,EAAA,CAAE,YAAY;EAAE;EAC1F,KAAK,QAAQ,IAAI,SAAS,KAAK;EAC/B,OAAO;CACT;CAEA,MAAc,UAAyB;EACrC,IAAI,KAAK,UAAU,KAAK,QAAQ,aAAa;GAC3C,KAAK,WAAW;GAChB;EACF;EACA,MAAM,IAAI,SAAe,YAAY;GACnC,KAAK,QAAQ,KAAK,OAAO;EAC3B,CAAC;EACD,MAAM,KAAK,QAAQ;CACrB;AACF"}
1
+ {"version":3,"file":"queue.mjs","names":[],"sources":["../../src/capture/queue.ts"],"sourcesContent":["import type { Logger } from \"pino\";\n\nimport type { CaptureJob, CaptureQueue, QueueEntry } from \"../adapters/capture-queue.ts\";\n\ndeclare const __PKG_VERSION__: string | undefined;\n\n/** Options for the in-process capture queue. */\nexport interface InMemoryCaptureQueueOptions {\n concurrency: number;\n runJob: (job: CaptureJob) => Promise<void>;\n logger?: Logger;\n}\n/** In-process capture queue with bounded concurrency. */\nexport class InMemoryCaptureQueue implements CaptureQueue {\n readonly metadata = {\n name: \"In-Memory Queue\",\n version: (globalThis as unknown as { __PKG_VERSION__?: string }).__PKG_VERSION__ ?? \"0.0.0\",\n description: \"In-process capture queue\",\n kind: \"memory\",\n } as const;\n\n private readonly entries = new Map<string, QueueEntry>();\n private running = 0;\n private readonly waiting: (() => void)[] = [];\n private readonly inFlight = new Map<string, Promise<void>>();\n\n constructor(private readonly options: InMemoryCaptureQueueOptions) {}\n\n async enqueue(job: CaptureJob): Promise<void> {\n const entry = this.track(job.buildId);\n this.log(job)?.info(\"capture queued\");\n this.inFlight.set(job.buildId, this.process(job, entry));\n await Promise.resolve();\n }\n\n async status(buildId: string): Promise<QueueEntry | null> {\n return await Promise.resolve(this.entries.get(buildId) ?? null);\n }\n\n async active(): Promise<QueueEntry[]> {\n return await Promise.resolve([...this.entries.values()]\n .filter((entry) => entry.status === \"queued\" || entry.status === \"running\")\n .toSorted((a, b) => a.queuedAt.localeCompare(b.queuedAt)));\n }\n\n async recent(limit: number): Promise<QueueEntry[]> {\n return await Promise.resolve([...this.entries.values()].toSorted((a, b) => b.queuedAt.localeCompare(a.queuedAt)).slice(0, limit));\n }\n\n private async process(job: CaptureJob, entry: QueueEntry): Promise<void> {\n const log = this.log(job);\n await this.acquire();\n entry.status = \"running\";\n entry.startedAt = new Date().toISOString();\n log?.info(\"capture started\");\n try {\n await this.options.runJob(job);\n } catch (error) {\n entry.status = \"failed\";\n entry.finishedAt = new Date().toISOString();\n entry.error = error instanceof Error ? error.message : \"Capture failed\";\n log?.error({ err: error }, \"capture failed\");\n } finally {\n this.running -= 1;\n this.waiting.shift()?.();\n if (entry.status !== \"failed\") {\n entry.status = \"completed\";\n entry.finishedAt = new Date().toISOString();\n log?.info(\"capture completed\");\n }\n }\n }\n\n private log(job: CaptureJob): Logger | undefined {\n return this.options.logger?.child({ buildId: job.buildId, reqId: job.reqId });\n }\n\n private track(buildId: string): QueueEntry {\n const existing = this.entries.get(buildId);\n if (existing) {\n existing.status = \"queued\";\n existing.queuedAt = new Date().toISOString();\n existing.startedAt = undefined;\n existing.finishedAt = undefined;\n existing.error = undefined;\n return existing;\n }\n const entry: QueueEntry = { buildId, status: \"queued\", queuedAt: new Date().toISOString() };\n this.entries.set(buildId, entry);\n return entry;\n }\n\n private async acquire(): Promise<void> {\n if (this.running < this.options.concurrency) {\n this.running += 1;\n return;\n }\n await new Promise<void>((resolve) => {\n this.waiting.push(resolve);\n });\n await this.acquire();\n }\n}"],"mappings":";;;;;AAaA,IAAa,uBAAb,MAA0D;CAa3B;CAZ7B,WAAoB;EAClB,MAAM;EACN,SAAU,WAAuD,mBAAmB;EACpF,aAAa;EACb,MAAM;CACR;CAEA,0BAA2B,IAAI,IAAwB;CACvD,UAAkB;CAClB,UAA2C,CAAC;CAC5C,2BAA4B,IAAI,IAA2B;CAE3D,YAAY,SAAuD;EAAtC,KAAA,UAAA;CAAuC;CAEpE,MAAM,QAAQ,KAAgC;EAC5C,MAAM,QAAQ,KAAK,MAAM,IAAI,OAAO;EACpC,KAAK,IAAI,GAAG,CAAC,EAAE,KAAK,gBAAgB;EACpC,KAAK,SAAS,IAAI,IAAI,SAAS,KAAK,QAAQ,KAAK,KAAK,CAAC;EACvD,MAAM,QAAQ,QAAQ;CACxB;CAEA,MAAM,OAAO,SAA6C;EACxD,OAAO,MAAM,QAAQ,QAAQ,KAAK,QAAQ,IAAI,OAAO,KAAK,IAAI;CAChE;CAEA,MAAM,SAAgC;EACpC,OAAO,MAAM,QAAQ,QAAQ,CAAC,GAAG,KAAK,QAAQ,OAAO,CAAC,CAAC,CACpD,QAAQ,UAAU,MAAM,WAAW,YAAY,MAAM,WAAW,SAAS,CAAC,CAC1E,UAAU,GAAG,MAAM,EAAE,SAAS,cAAc,EAAE,QAAQ,CAAC,CAAC;CAC7D;CAEA,MAAM,OAAO,OAAsC;EACjD,OAAO,MAAM,QAAQ,QAAQ,CAAC,GAAG,KAAK,QAAQ,OAAO,CAAC,CAAC,CAAC,UAAU,GAAG,MAAM,EAAE,SAAS,cAAc,EAAE,QAAQ,CAAC,CAAC,CAAC,MAAM,GAAG,KAAK,CAAC;CAClI;CAEA,MAAc,QAAQ,KAAiB,OAAkC;EACvE,MAAM,MAAM,KAAK,IAAI,GAAG;EACxB,MAAM,KAAK,QAAQ;EACnB,MAAM,SAAS;EACf,MAAM,6BAAY,IAAI,KAAK,EAAA,CAAE,YAAY;EACzC,KAAK,KAAK,iBAAiB;EAC3B,IAAI;GACF,MAAM,KAAK,QAAQ,OAAO,GAAG;EAC/B,SAAS,OAAO;GACd,MAAM,SAAS;GACf,MAAM,8BAAa,IAAI,KAAK,EAAA,CAAE,YAAY;GAC1C,MAAM,QAAQ,iBAAiB,QAAQ,MAAM,UAAU;GACvD,KAAK,MAAM,EAAE,KAAK,MAAM,GAAG,gBAAgB;EAC7C,UAAU;GACR,KAAK,WAAW;GAChB,KAAK,QAAQ,MAAM,CAAC,GAAG;GACvB,IAAI,MAAM,WAAW,UAAU;IAC7B,MAAM,SAAS;IACf,MAAM,8BAAa,IAAI,KAAK,EAAA,CAAE,YAAY;IAC1C,KAAK,KAAK,mBAAmB;GAC/B;EACF;CACF;CAEA,IAAY,KAAqC;EAC/C,OAAO,KAAK,QAAQ,QAAQ,MAAM;GAAE,SAAS,IAAI;GAAS,OAAO,IAAI;EAAM,CAAC;CAC9E;CAEA,MAAc,SAA6B;EACzC,MAAM,WAAW,KAAK,QAAQ,IAAI,OAAO;EACzC,IAAI,UAAU;GACZ,SAAS,SAAS;GAClB,SAAS,4BAAW,IAAI,KAAK,EAAA,CAAE,YAAY;GAC3C,SAAS,YAAY,KAAA;GACrB,SAAS,aAAa,KAAA;GACtB,SAAS,QAAQ,KAAA;GACjB,OAAO;EACT;EACA,MAAM,QAAoB;GAAE;GAAS,QAAQ;GAAU,2BAAU,IAAI,KAAK,EAAA,CAAE,YAAY;EAAE;EAC1F,KAAK,QAAQ,IAAI,SAAS,KAAK;EAC/B,OAAO;CACT;CAEA,MAAc,UAAyB;EACrC,IAAI,KAAK,UAAU,KAAK,QAAQ,aAAa;GAC3C,KAAK,WAAW;GAChB;EACF;EACA,MAAM,IAAI,SAAe,YAAY;GACnC,KAAK,QAAQ,KAAK,OAAO;EAC3B,CAAC;EACD,MAAM,KAAK,QAAQ;CACrB;AACF"}