@webship.co/workspace 0.0.0-stage → 1.0.0-beta1

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 (307) hide show
  1. package/PLUGINS.md +249 -0
  2. package/README.md +379 -2
  3. package/WORKSPACES.md +69 -0
  4. package/actions/ai-items.js +293 -0
  5. package/actions/audio.js +48 -0
  6. package/actions/build-order.js +79 -0
  7. package/actions/checks.js +95 -0
  8. package/actions/claude-session.js +104 -0
  9. package/actions/commands-center.js +258 -0
  10. package/actions/composer.js +267 -0
  11. package/actions/ddev-addons.js +90 -0
  12. package/actions/ddev-mail.js +80 -0
  13. package/actions/ddev.js +237 -0
  14. package/actions/folders.js +165 -0
  15. package/actions/images-svg.js +33 -0
  16. package/actions/images.js +145 -0
  17. package/actions/navigation.js +190 -0
  18. package/actions/node.js +222 -0
  19. package/actions/notes.js +55 -0
  20. package/actions/plugins.js +36 -0
  21. package/actions/project-make.js +28 -0
  22. package/actions/rag.js +202 -0
  23. package/actions/row-actions.js +262 -0
  24. package/actions/screens.js +270 -0
  25. package/actions/testing.js +121 -0
  26. package/actions/video-edit.js +54 -0
  27. package/ai-inventory.js +182 -0
  28. package/ai-items.js +202 -0
  29. package/assistant-cast.js +176 -0
  30. package/assistant-suggestions.js +91 -0
  31. package/assistant.js +484 -0
  32. package/audio-voice.js +528 -0
  33. package/auth-google.js +260 -0
  34. package/build-ai.js +52 -0
  35. package/build-order-page.js +191 -0
  36. package/build-order.js +313 -0
  37. package/builder-args.js +117 -0
  38. package/changelog.js +302 -0
  39. package/chat-threads.js +134 -0
  40. package/checks.js +105 -0
  41. package/claude-args.js +282 -0
  42. package/claude-sessions.js +129 -0
  43. package/command-author.js +117 -0
  44. package/command-block.js +20 -0
  45. package/commands/defaults.yml +159 -0
  46. package/commands-page.js +460 -0
  47. package/commands.js +568 -0
  48. package/cucumber.js +43 -0
  49. package/ddev-addons-page.js +124 -0
  50. package/ddev-addons.js +154 -0
  51. package/ddev-custom-commands.js +96 -0
  52. package/ddev-domains.js +84 -0
  53. package/ddev-import.js +93 -0
  54. package/ddev-mail-page.js +167 -0
  55. package/ddev-mail.js +110 -0
  56. package/ddev-options.js +145 -0
  57. package/ddev-snapshots.js +126 -0
  58. package/ddev.js +145 -0
  59. package/docs/DEVELOPER-GUIDE.md +224 -0
  60. package/docs/README.md +252 -0
  61. package/docs/REFERENCE.md +684 -0
  62. package/docs/USER-GUIDE.md +697 -0
  63. package/docs/screenshots/assistant/avatar-board.png +0 -0
  64. package/docs/screenshots/assistant/avatar-coffee.png +0 -0
  65. package/docs/screenshots/assistant/avatar-colours.png +0 -0
  66. package/docs/screenshots/assistant/avatar-icons.png +0 -0
  67. package/docs/screenshots/assistant/avatar-moves.png +0 -0
  68. package/docs/screenshots/assistant/avatar-pick-cookieman.png +0 -0
  69. package/docs/screenshots/assistant/avatar-settings-voices.png +0 -0
  70. package/docs/screenshots/assistant/avatar-status.png +0 -0
  71. package/docs/screenshots/create-card/create-card.png +0 -0
  72. package/docs/screenshots/create-card/start-ai.png +0 -0
  73. package/docs/screenshots/guide-clone-address.png +0 -0
  74. package/docs/screenshots/guide-clone-worktree-off.png +0 -0
  75. package/docs/screenshots/guide-clone-worktree-on.png +0 -0
  76. package/docs/screenshots/guide-ddev-menu.png +0 -0
  77. package/docs/screenshots/guide-domains.png +0 -0
  78. package/docs/screenshots/guide-home.png +0 -0
  79. package/docs/screenshots/guide-ide-menu.png +0 -0
  80. package/docs/screenshots/guide-remove-refusal.png +0 -0
  81. package/docs/screenshots/guide-rows-worktree.png +0 -0
  82. package/docs/screenshots/guide-screens.png +0 -0
  83. package/docs/screenshots/guide-settings.png +0 -0
  84. package/docs/screenshots/guide-workspace-page.png +0 -0
  85. package/docs/screenshots/home/card-settings.png +0 -0
  86. package/docs/screenshots/home/drag-reorder.png +0 -0
  87. package/docs/screenshots/home/edit-card.png +0 -0
  88. package/docs/screenshots/images/claude-code-image.png +0 -0
  89. package/docs/screenshots/images/edit-with-ai.png +0 -0
  90. package/docs/screenshots/images/gallery.png +0 -0
  91. package/docs/screenshots/images/generate-description.png +0 -0
  92. package/docs/screenshots/images/generate-plan.png +0 -0
  93. package/docs/screenshots/images/providers.png +0 -0
  94. package/docs/screenshots/images/svg-form.png +0 -0
  95. package/docs/screenshots/images/svg-result.png +0 -0
  96. package/docs/screenshots/images/upload.png +0 -0
  97. package/docs/screenshots/new-folder/2-form.png +0 -0
  98. package/docs/screenshots/new-folder/8-ddev-options.png +0 -0
  99. package/docs/screenshots/new-folder/files-folder.png +0 -0
  100. package/docs/screenshots/projects/ddev-custom-commands.png +0 -0
  101. package/docs/screenshots/projects/state-chip-stop.png +0 -0
  102. package/docs/screenshots/readme/dashboard-dark.png +0 -0
  103. package/docs/screenshots/readme/dashboard-light.png +0 -0
  104. package/docs/screenshots/readme/dev-dark.png +0 -0
  105. package/docs/screenshots/readme/dev-light.png +0 -0
  106. package/docs/screenshots/readme/docs-dark.png +0 -0
  107. package/docs/screenshots/readme/docs-light.png +0 -0
  108. package/docs/screenshots/readme/icons/ai.svg +4 -0
  109. package/docs/screenshots/readme/icons/code.svg +4 -0
  110. package/docs/screenshots/readme/icons/docs.svg +4 -0
  111. package/docs/screenshots/readme/icons/services.svg +4 -0
  112. package/docs/screenshots/readme/icons/sites.svg +4 -0
  113. package/docs/screenshots/readme/test-report-dark.png +0 -0
  114. package/docs/screenshots/readme/test-report-light.png +0 -0
  115. package/docs/screenshots/readme/test-scenarios.png +0 -0
  116. package/docs/screenshots/readme/test-steps.png +0 -0
  117. package/docs/screenshots/readme/videos-dark.png +0 -0
  118. package/docs/screenshots/readme/videos-light.png +0 -0
  119. package/docs/screenshots/screens-menu.png +0 -0
  120. package/docs/screenshots/screens-new.png +0 -0
  121. package/docs/screenshots/settings/auth-google-lists.png +0 -0
  122. package/docs/screenshots/settings/delete-1-row.png +0 -0
  123. package/docs/screenshots/settings/delete-2-confirm.png +0 -0
  124. package/docs/screenshots/settings/delete-3-type-name.png +0 -0
  125. package/docs/screenshots/settings/delete-4-type-yes.png +0 -0
  126. package/event-log.js +59 -0
  127. package/files-browser.js +247 -0
  128. package/folders.js +121 -0
  129. package/format.js +73 -0
  130. package/git-info.js +81 -0
  131. package/github-auth.js +62 -0
  132. package/html.js +10 -0
  133. package/icons.js +83 -0
  134. package/images-ai.js +447 -0
  135. package/images-create.js +226 -0
  136. package/images-page.js +417 -0
  137. package/images-records.js +39 -0
  138. package/images-render.js +36 -0
  139. package/images-svg-core.js +214 -0
  140. package/images-svg-sanitize.js +188 -0
  141. package/images-svg.js +241 -0
  142. package/images.js +270 -0
  143. package/jobs.js +430 -0
  144. package/linger.js +21 -0
  145. package/lists.js +292 -0
  146. package/media-info.js +136 -0
  147. package/navigation-page.js +264 -0
  148. package/navigation.js +394 -0
  149. package/notes.js +0 -0
  150. package/package.json +47 -4
  151. package/palette-index.js +46 -0
  152. package/playwright.config.js +23 -0
  153. package/plugin-install.js +239 -0
  154. package/plugin-loader.js +466 -0
  155. package/plugin-manifest.js +83 -0
  156. package/plugin-settings.js +44 -0
  157. package/plugins/workspace-notes/index.js +59 -0
  158. package/plugins-page.js +103 -0
  159. package/project-make.js +241 -0
  160. package/project-name.js +210 -0
  161. package/public/assistant-avatar-moves.js +646 -0
  162. package/public/assistant-avatar-ui.js +1223 -0
  163. package/public/assistant-avatar.js +530 -0
  164. package/public/avatar/druplicon.vrm +0 -0
  165. package/public/css/base.css +260 -0
  166. package/public/css/components/ai-assistant.css +263 -0
  167. package/public/css/components/assistant-avatar.css +279 -0
  168. package/public/css/components/assistant-views.css +198 -0
  169. package/public/css/components/build-order.css +74 -0
  170. package/public/css/components/clone-as-a-git-worktree.css +30 -0
  171. package/public/css/components/command-palette.css +61 -0
  172. package/public/css/components/commands-center.css +139 -0
  173. package/public/css/components/confirm-dialog.css +40 -0
  174. package/public/css/components/create-card.css +8 -0
  175. package/public/css/components/ddev-menu.css +23 -0
  176. package/public/css/components/deep-chat.css +17 -0
  177. package/public/css/components/density.css +11 -0
  178. package/public/css/components/floating-assistant-workspace-pages.css +55 -0
  179. package/public/css/components/folders.css +5 -0
  180. package/public/css/components/full-height-left-assistant-sidebar-all-pages.css +39 -0
  181. package/public/css/components/full-height-right-actions-rail-pages-that-have-actions.css +267 -0
  182. package/public/css/components/grid-and-list.css +203 -0
  183. package/public/css/components/help-page.css +36 -0
  184. package/public/css/components/hero.css +16 -0
  185. package/public/css/components/images.css +70 -0
  186. package/public/css/components/layout-rhythm-and-spacing-polish.css +139 -0
  187. package/public/css/components/mic-on-any-field-that-wants-typing.css +416 -0
  188. package/public/css/components/modal-fit.css +113 -0
  189. package/public/css/components/page-width.css +23 -0
  190. package/public/css/components/plugins-page.css +9 -0
  191. package/public/css/components/project-cards.css +256 -0
  192. package/public/css/components/project-rows.css +390 -0
  193. package/public/css/components/reordering-the-workspace-cards.css +85 -0
  194. package/public/css/components/responsive-home-and-the-workspace-pages.css +457 -0
  195. package/public/css/components/running-commands-as-toasts.css +327 -0
  196. package/public/css/components/settings-editor-core-config-yml.css +828 -0
  197. package/public/css/components/short-results-as-a-toast-at-the-top.css +203 -0
  198. package/public/css/components/sound-player.css +84 -0
  199. package/public/css/components/suggest-agents-skills-commands.css +112 -0
  200. package/public/css/components/tab-modal.css +142 -0
  201. package/public/css/components/terminals.css +244 -0
  202. package/public/css/components/test-report-media-one-file-per-row-paged.css +144 -0
  203. package/public/css/components/top-toolbar-logo-breadcrumb.css +107 -0
  204. package/public/css/components/video-edit.css +6 -0
  205. package/public/css/components/web-ide-open-with.css +23 -0
  206. package/public/css/components/working-indicators.css +158 -0
  207. package/public/css/components/workspace-cards.css +79 -0
  208. package/public/favicon.ico +0 -0
  209. package/public/favicon.png +0 -0
  210. package/public/htmx.min.js +1 -0
  211. package/public/logo.png +0 -0
  212. package/public/media-player.js +215 -0
  213. package/public/mic.js +255 -0
  214. package/public/narration.js +110 -0
  215. package/public/palette.js +278 -0
  216. package/public/sortable.js +251 -0
  217. package/public/suggest.js +337 -0
  218. package/public/terminal.js +903 -0
  219. package/public/themes/default/theme.css +267 -0
  220. package/public/themes/ember/theme.css +180 -0
  221. package/public/themes/press/theme.css +290 -0
  222. package/public/themes/slate/theme.css +182 -0
  223. package/public/touch-icon.png +0 -0
  224. package/public/ui.js +2721 -0
  225. package/public/vendor/ace/LICENSE.txt +24 -0
  226. package/public/vendor/ace/ace.js +23 -0
  227. package/public/vendor/ace/ext-language_tools.js +8 -0
  228. package/public/vendor/ace/ext-searchbox.js +8 -0
  229. package/public/vendor/ace/mode-css.js +8 -0
  230. package/public/vendor/ace/mode-html.js +8 -0
  231. package/public/vendor/ace/mode-javascript.js +8 -0
  232. package/public/vendor/ace/mode-markdown.js +8 -0
  233. package/public/vendor/ace/mode-sh.js +8 -0
  234. package/public/vendor/ace/mode-yaml.js +8 -0
  235. package/public/vendor/ace/theme-textmate.js +8 -0
  236. package/public/vendor/ace/theme-tomorrow_night.js +8 -0
  237. package/public/vendor/ace/worker-yaml.js +1 -0
  238. package/public/vendor/deep-chat.LICENSE +21 -0
  239. package/public/vendor/deep-chat.bundle.js +1 -0
  240. package/public/vendor/fonts/dm-serif-text-LICENSE.txt +93 -0
  241. package/public/vendor/fonts/dm-serif-text-latin-400-italic.woff2 +0 -0
  242. package/public/vendor/fonts/dm-serif-text-latin-400-normal.woff2 +0 -0
  243. package/public/vendor/fonts/figtree-LICENSE.txt +93 -0
  244. package/public/vendor/fonts/figtree-latin-400-italic.woff2 +0 -0
  245. package/public/vendor/fonts/figtree-latin-400-normal.woff2 +0 -0
  246. package/public/vendor/fonts/figtree-latin-600-italic.woff2 +0 -0
  247. package/public/vendor/fonts/figtree-latin-600-normal.woff2 +0 -0
  248. package/public/vendor/fonts/jetbrains-mono-LICENSE.txt +93 -0
  249. package/public/vendor/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
  250. package/public/vendor/fonts/ubuntu-mono-LICENSE.txt +98 -0
  251. package/public/vendor/fonts/ubuntu-mono-latin-wght-normal.woff2 +0 -0
  252. package/public/vendor/tabler-LICENSE.txt +21 -0
  253. package/public/vendor/tabler-sprite.svg +1 -0
  254. package/public/vendor/three/LICENSE +21 -0
  255. package/public/vendor/three/addons/loaders/GLTFLoader.js +4860 -0
  256. package/public/vendor/three/addons/utils/BufferGeometryUtils.js +1501 -0
  257. package/public/vendor/three/addons/utils/SkeletonUtils.js +496 -0
  258. package/public/vendor/three/three.core.min.js +6 -0
  259. package/public/vendor/three/three.module.min.js +6 -0
  260. package/public/vendor/three-vrm/LICENSE +22 -0
  261. package/public/vendor/three-vrm/three-vrm.module.min.js +983 -0
  262. package/public/vendor/uikit-icons.min.js +1 -0
  263. package/public/vendor/uikit.min.css +1 -0
  264. package/public/vendor/uikit.min.js +1 -0
  265. package/public/vendor/webship-icons/ui/bookmark-outlined.svg +1 -0
  266. package/public/vendor/webship-icons/ui/headset.svg +1 -0
  267. package/public/vendor/webship-icons/ui/menu.svg +1 -0
  268. package/public/vendor/webship-icons/ui/pause.svg +1 -0
  269. package/public/vendor/webship-icons/ui/play.svg +1 -0
  270. package/public/vendor/webship-icons/ui/share.svg +1 -0
  271. package/public/vendor/webship-icons/ui/sound-off.svg +1 -0
  272. package/public/vendor/webship-icons/ui/sound-on.svg +1 -0
  273. package/public/vendor/webship-icons/ui/stop.svg +1 -0
  274. package/public/vendor/xterm/addon-fit.js +2 -0
  275. package/public/vendor/xterm/addon-unicode11.js +2 -0
  276. package/public/vendor/xterm/addon-web-links.js +2 -0
  277. package/public/vendor/xterm/xterm.css +218 -0
  278. package/public/vendor/xterm/xterm.js +2 -0
  279. package/rag.js +169 -0
  280. package/reader.js +132 -0
  281. package/repo-blobs.js +66 -0
  282. package/report-pdf.js +337 -0
  283. package/row-layout.js +1439 -0
  284. package/screenrc +37 -0
  285. package/screens.js +1063 -0
  286. package/scripts/code-server-start.sh +190 -0
  287. package/scripts/images-claude-finish.js +107 -0
  288. package/scripts/screen-host.sh +114 -0
  289. package/scripts/screen-runner.js +362 -0
  290. package/scripts/screen-then.js +47 -0
  291. package/scripts/update.sh +350 -0
  292. package/server.js +7923 -0
  293. package/settings-file.js +423 -0
  294. package/speech.js +297 -0
  295. package/tab-modal.js +75 -0
  296. package/test-reports.js +260 -0
  297. package/themes.js +381 -0
  298. package/transcripts.js +168 -0
  299. package/urls.js +13 -0
  300. package/validate.js +18 -0
  301. package/version-check.js +94 -0
  302. package/video-edit.js +588 -0
  303. package/waiting.js +85 -0
  304. package/workspace-card-tools.js +41 -0
  305. package/workspace-delete.js +139 -0
  306. package/workspace-types.js +85 -0
  307. package/workspaces.js +553 -0
package/report-pdf.js ADDED
@@ -0,0 +1,337 @@
1
+ /**
2
+ * The automated-testing report, rendered for paper.
3
+ *
4
+ * Built from the run's JSON rather than from cucumber-html-reporter's page. Printing that page
5
+ * produced an unreadable PDF and it was not fixable there: its feature and scenario bodies are
6
+ * Bootstrap 3 collapsibles that open only once jQuery and Bootstrap have loaded from a CDN and
7
+ * somebody has clicked, so a print is a list of closed headers — and offline it is unstyled on top
8
+ * of that. The JSON carries everything the page shows, so this renders it directly.
9
+ *
10
+ * The audience is not the person who ran it. This goes to a product owner, a PMO and a QA lead, so
11
+ * it opens with the verdict and the shape of the run, puts the failures first (that is the part
12
+ * that gets acted on), and only then lists every scenario and step. It is a document, not a dump.
13
+ *
14
+ * Rendered by WeasyPrint, which honours @page, running footers, `break-inside: avoid` and inline
15
+ * SVG. Chromium prints too, but ignores most of that and re-fetches the CDN assets.
16
+ */
17
+
18
+ const fs = require('fs');
19
+ const path = require('path');
20
+
21
+ const { esc } = require('./html');
22
+ const { humanTime } = require('./format');
23
+ const { CONFIG_DIR } = require('./workspaces');
24
+
25
+ // Status colours are the one place this file names a colour outright: a PDF has no theme to
26
+ // follow, and green-for-passed has to be the same green in every report anybody compares.
27
+ const OK = '#1f8a4c';
28
+ const BAD = '#c0362c';
29
+ const WARN = '#b26a00';
30
+ const MUTED = '#8a8a8a';
31
+
32
+ const ms = (ns) => (ns ? `${(ns / 1e6).toFixed(0)} ms` : '—');
33
+
34
+ // Long runs are reported in the unit a person would say out loud.
35
+ function humanDuration(ns) {
36
+ const s = ns / 1e9;
37
+ if (!s) return '—';
38
+ if (s < 60) return `${s.toFixed(1)} s`;
39
+ const m = Math.floor(s / 60);
40
+ if (m < 60) return `${m} min ${Math.round(s - m * 60)} s`;
41
+ return `${Math.floor(m / 60)} h ${m % 60} min`;
42
+ }
43
+
44
+ function statusOf(steps) {
45
+ const states = steps.map((st) => (st.result || {}).status);
46
+ return states.includes('failed') ? 'failed'
47
+ : states.includes('undefined') || states.includes('ambiguous') ? 'undefined'
48
+ : states.some((x) => x === 'passed') ? 'passed' : 'skipped';
49
+ }
50
+
51
+ // A pass-rate ring, drawn as inline SVG rather than fetched from a chart library: it has to render
52
+ // with no network and inside WeasyPrint, and one arc is not worth a dependency.
53
+ function donut(passed, failed, skipped, total) {
54
+ if (!total) return '';
55
+ const R = 26, C = 2 * Math.PI * R;
56
+ const seg = (n, colour, offset) => (n
57
+ ? `<circle cx="34" cy="34" r="${R}" fill="none" stroke="${colour}" stroke-width="12"
58
+ stroke-dasharray="${((n / total) * C).toFixed(2)} ${C.toFixed(2)}"
59
+ stroke-dashoffset="${(-offset / total * C).toFixed(2)}" transform="rotate(-90 34 34)"/>`
60
+ : '');
61
+ const pct = Math.round((passed / total) * 100);
62
+ return `<svg class="donut" width="68" height="68" viewBox="0 0 68 68" aria-hidden="true">
63
+ <circle cx="34" cy="34" r="${R}" fill="none" stroke="#e8e8e8" stroke-width="12"/>
64
+ ${seg(passed, OK, 0)}${seg(failed, BAD, passed)}${seg(skipped, MUTED, passed + failed)}
65
+ <text x="34" y="38" text-anchor="middle" font-size="15" font-weight="700">${pct}%</text>
66
+ </svg>`;
67
+ }
68
+
69
+ /**
70
+ * @param {string} key the workspace the project is in
71
+ * @param {string} project the project directory name
72
+ * @param {object} report the report record (json, html, mtime, rel, shots, videos)
73
+ * @param {string} dir the workspace directory
74
+ */
75
+ function testReportPdfHtml(key, project, report, dir) {
76
+ let data = [];
77
+ try { data = JSON.parse(fs.readFileSync(path.join(dir, project, report.json), 'utf8')); } catch (_) { data = []; }
78
+ const brand = (() => {
79
+ try { return JSON.parse(fs.readFileSync(path.join(CONFIG_DIR, 'brand-tokens.json'), 'utf8')).tokens || {}; } catch (_) { return {}; }
80
+ })();
81
+ const c = (name, fallback) => brand[name] || fallback;
82
+
83
+ // A failed step's screenshot, inlined: a PDF that references a file on this machine shows
84
+ // nothing to whoever it is sent to. Matched by the stem convention webship-js writes.
85
+ const shotFor = (scenarioName) => {
86
+ const stem = scenarioName.toLowerCase().replace(/[^a-z0-9]+/g, '');
87
+ const hit = (report.shots || []).find((f) => f.name.toLowerCase().replace(/[^a-z0-9]+/g, '').includes(stem.slice(0, 24)));
88
+ if (!hit) return null;
89
+ try {
90
+ const full = path.join(dir, project, hit.file);
91
+ // A report of a bad run can carry a hundred of these. Above 1.5 MB the picture is a full-page
92
+ // screenshot whose detail the PDF cannot show anyway, and ten of them make a file nobody can
93
+ // email — the report says the screenshot exists and where, instead.
94
+ if (fs.statSync(full).size > 1.5 * 1024 * 1024) return { oversize: hit.name };
95
+ const b64 = fs.readFileSync(full).toString('base64');
96
+ const type = /\.jpe?g$/i.test(hit.file) ? 'jpeg' : 'png';
97
+ return { src: `data:image/${type};base64,${b64}`, name: hit.name };
98
+ } catch (_) { return null; }
99
+ };
100
+
101
+ const shotHtml = (scenarioName) => {
102
+ const s = shotFor(scenarioName);
103
+ if (!s) return '';
104
+ if (s.oversize) {
105
+ return `<p class="shot-note">Screenshot too large to embed: <code>${esc(s.oversize)}</code> — see the run's screenshots directory.</p>`;
106
+ }
107
+ return `<figure class="shot"><img src="${s.src}" alt=""><figcaption>${esc(s.name)}</figcaption></figure>`;
108
+ };
109
+
110
+ // One pass over the JSON, collecting what both the summary and the detail need.
111
+ let scenarios = 0, passed = 0, failed = 0, skipped = 0, stepCount = 0, totalNs = 0;
112
+ const featureStats = [];
113
+ const failures = [];
114
+
115
+ const features = data.map((feat) => {
116
+ const featName = feat.name || '(unnamed feature)';
117
+ let fScen = 0, fPass = 0, fFail = 0, fSkip = 0, fNs = 0;
118
+
119
+ const rows = (feat.elements || []).filter((sc) => !sc.type || sc.type === 'scenario').map((sc) => {
120
+ const steps = (sc.steps || []).filter((st) => !st.hidden);
121
+ const status = statusOf(steps);
122
+ const scNs = steps.reduce((n, st) => n + ((st.result || {}).duration || 0), 0);
123
+ scenarios += 1; stepCount += steps.length; totalNs += scNs;
124
+ fScen += 1; fNs += scNs;
125
+ if (status === 'passed') { passed += 1; fPass += 1; } else if (status === 'failed') { failed += 1; fFail += 1; } else { skipped += 1; fSkip += 1; }
126
+
127
+ const tags = (sc.tags || []).map((t) => t.name).join(' ');
128
+ const stepRows = steps.map((st) => {
129
+ const r = st.result || {};
130
+ const err = r.error_message
131
+ ? `<tr class="err"><td colspan="3"><pre>${esc(String(r.error_message).slice(0, 4000))}</pre></td></tr>` : '';
132
+ return `<tr class="s-${esc(r.status || 'unknown')}">
133
+ <td class="kw">${esc(st.keyword || '')}</td>
134
+ <td>${esc(st.name || '')}</td>
135
+ <td class="num">${esc(ms(r.duration))}</td>
136
+ </tr>${err}`;
137
+ }).join('');
138
+
139
+ if (status === 'failed') {
140
+ const firstErr = steps.map((st) => (st.result || {}).error_message).find(Boolean) || '';
141
+ const failedStep = steps.find((st) => (st.result || {}).status === 'failed');
142
+ failures.push({
143
+ feature: featName,
144
+ name: sc.name || '(unnamed)',
145
+ step: failedStep ? `${failedStep.keyword || ''}${failedStep.name || ''}`.trim() : '',
146
+ error: String(firstErr).slice(0, 1200),
147
+ shot: shotHtml(sc.name || ''),
148
+ });
149
+ }
150
+
151
+ return `<section class="scenario ${status}">
152
+ <h3><span class="pill ${status}">${status}</span> ${esc(sc.name || '(unnamed)')}</h3>
153
+ ${tags ? `<p class="tags">${esc(tags)}</p>` : ''}
154
+ <table class="steps"><tbody>${stepRows}</tbody></table>
155
+ <p class="scenario-meta">${steps.length} steps · ${esc(ms(scNs))}</p>
156
+ </section>`;
157
+ }).join('');
158
+
159
+ if (!rows) return '';
160
+ featureStats.push({ name: featName, uri: feat.uri || '', scenarios: fScen, passed: fPass, failed: fFail, skipped: fSkip, ns: fNs });
161
+ return `<section class="feature">
162
+ <h2>${esc(featName)}</h2>
163
+ <p class="feature-meta">${fScen} scenario${fScen === 1 ? '' : 's'} ·
164
+ <span class="ok">${fPass} passed</span>${fFail ? ` · <span class="bad">${fFail} failed</span>` : ''}${fSkip ? ` · ${fSkip} skipped` : ''} ·
165
+ ${esc(humanDuration(fNs))}${feat.uri ? ` · <span class="uri">${esc(feat.uri)}</span>` : ''}</p>
166
+ ${rows}</section>`;
167
+ }).join('');
168
+
169
+ const pct = scenarios ? Math.round((passed / scenarios) * 100) : 0;
170
+ const verdict = failed ? 'FAILED' : scenarios ? 'PASSED' : 'NO SCENARIOS';
171
+
172
+ const featureTable = featureStats.length ? `
173
+ <table class="glance">
174
+ <thead><tr><th>Feature</th><th class="n">Scenarios</th><th class="n">Passed</th><th class="n">Failed</th><th class="n">Pass rate</th><th class="n">Time</th></tr></thead>
175
+ <tbody>${featureStats.map((f) => `
176
+ <tr class="${f.failed ? 'has-failures' : ''}">
177
+ <td>${esc(f.name)}</td>
178
+ <td class="n">${f.scenarios}</td>
179
+ <td class="n ok">${f.passed}</td>
180
+ <td class="n ${f.failed ? 'bad' : ''}">${f.failed || '—'}</td>
181
+ <td class="n">${f.scenarios ? Math.round((f.passed / f.scenarios) * 100) : 0}%</td>
182
+ <td class="n">${esc(humanDuration(f.ns))}</td>
183
+ </tr>`).join('')}</tbody>
184
+ </table>` : '';
185
+
186
+ const failuresSection = failures.length ? `
187
+ <section class="failures">
188
+ <h2 class="failures-h">What failed <span class="count">${failures.length}</span></h2>
189
+ <p class="section-lede">Every failing scenario, with the step it stopped on and the screenshot taken at that
190
+ moment. The full run follows.</p>
191
+ ${failures.map((f, i) => `
192
+ <section class="failure">
193
+ <h3><span class="idx">${i + 1}</span> ${esc(f.name)}</h3>
194
+ <p class="failure-where">${esc(f.feature)}${f.step ? ` · stopped on <strong>${esc(f.step)}</strong>` : ''}</p>
195
+ ${f.error ? `<pre class="failure-err">${esc(f.error)}</pre>` : ''}
196
+ ${f.shot}
197
+ </section>`).join('')}
198
+ </section>` : '';
199
+
200
+ return `<!doctype html>
201
+ <html lang="en"><head><meta charset="utf-8"><title>${esc(project)} — automated test report</title>
202
+ <style>
203
+ @page {
204
+ size: A4; margin: 20mm 16mm 18mm 16mm;
205
+ @top-right { content: "${esc(project)}"; font-size: 7.5pt; color: ${c('text-subtle', '#4d4d4d')}; }
206
+ @bottom-left { content: "Automated functional acceptance testing"; font-size: 7.5pt; color: ${c('text-subtle', '#4d4d4d')}; }
207
+ @bottom-right { content: "Page " counter(page) " of " counter(pages); font-size: 7.5pt; color: ${c('text-subtle', '#4d4d4d')}; }
208
+ }
209
+ /* The cover carries the running header nowhere: a title page with a header on it reads as
210
+ page two of something else. */
211
+ @page :first { @top-right { content: ""; } }
212
+
213
+ body { font-family: "DejaVu Sans", "Helvetica Neue", Arial, sans-serif; font-size: 9.5pt;
214
+ color: ${c('text-default', '#000')}; line-height: 1.45; }
215
+
216
+ /* ---- cover ---- */
217
+ .cover { border-bottom: 3px solid ${c('brand-primary', '#0074e0')}; padding-bottom: 5mm; margin-bottom: 6mm; }
218
+ .eyebrow { font-size: 8pt; letter-spacing: .10em; text-transform: uppercase;
219
+ color: ${c('brand-primary', '#0074e0')}; margin: 0 0 1mm; font-weight: 600; }
220
+ h1 { font-size: 22pt; margin: 0 0 2mm; color: ${c('brand-secondary', '#2b447c')}; line-height: 1.15; }
221
+ .lede { color: ${c('text-subtle', '#4d4d4d')}; margin: 0; font-size: 9pt; }
222
+ .verdict { display: inline-block; margin-top: 3mm; padding: 1.2mm 4mm; border-radius: 1mm;
223
+ font-size: 10pt; font-weight: 700; letter-spacing: .06em; color: #fff; background: ${MUTED}; }
224
+ .verdict.PASSED { background: ${OK}; } .verdict.FAILED { background: ${BAD}; }
225
+
226
+ /* ---- summary ---- */
227
+ .summary-row { display: flex; gap: 5mm; align-items: center; margin: 0 0 7mm; }
228
+ .donut-wrap { flex: 0 0 auto; text-align: center; }
229
+ .donut-wrap .cap { font-size: 7pt; text-transform: uppercase; letter-spacing: .04em;
230
+ color: ${c('text-subtle', '#4d4d4d')}; display: block; margin-top: 1mm; }
231
+ .summary { display: flex; gap: 2.5mm; flex: 1; }
232
+ .tile { flex: 1; border: 1px solid ${c('brand-light', '#ebe8e5')}; border-radius: 2mm;
233
+ padding: 2.5mm 1mm; text-align: center; background: ${c('subtle-light', '#f5f3f2')}; }
234
+ .tile .n { display: block; font-size: 15pt; font-weight: 700; line-height: 1.1; }
235
+ .tile .l { font-size: 6.8pt; text-transform: uppercase; letter-spacing: .04em;
236
+ color: ${c('text-subtle', '#4d4d4d')}; }
237
+ .tile.ok .n { color: ${OK}; } .tile.bad .n { color: ${BAD}; }
238
+ .tile.brand .n { color: ${c('brand-primary', '#0074e0')}; }
239
+
240
+ h2 { font-size: 13pt; margin: 8mm 0 2mm; color: ${c('brand-primary', '#0074e0')};
241
+ border-bottom: 2px solid ${c('subtle-brand', '#deedff')}; padding-bottom: 1.5mm;
242
+ break-after: avoid; }
243
+ h3 { font-size: 10.5pt; margin: 4mm 0 1.5mm; font-weight: 600; break-after: avoid; }
244
+ .section-lede { color: ${c('text-subtle', '#4d4d4d')}; font-size: 8.5pt; margin: 0 0 4mm; }
245
+
246
+ /* ---- features at a glance ---- */
247
+ table.glance { width: 100%; border-collapse: collapse; font-size: 8.5pt; margin: 0 0 4mm; }
248
+ table.glance th { text-align: left; font-size: 7.5pt; text-transform: uppercase; letter-spacing: .04em;
249
+ color: ${c('text-subtle', '#4d4d4d')}; border-bottom: 1pt solid ${c('brand-light', '#ebe8e5')};
250
+ padding: 1.5mm 1.5mm; }
251
+ table.glance td { padding: 1.2mm 1.5mm; border-bottom: 0.3pt solid ${c('brand-light', '#ebe8e5')};
252
+ vertical-align: top; }
253
+ table.glance th.n, table.glance td.n { text-align: right; white-space: nowrap; }
254
+ table.glance tr.has-failures td:first-child { border-left: 2px solid ${BAD}; padding-left: 1mm; }
255
+ .ok { color: ${OK}; } .bad { color: ${BAD}; font-weight: 600; }
256
+
257
+ /* ---- what failed ---- */
258
+ .failures-h .count { display: inline-block; background: ${BAD}; color: #fff; font-size: 8pt;
259
+ border-radius: 1mm; padding: 0.3mm 2mm; vertical-align: middle; margin-left: 1.5mm; }
260
+ .failure { break-inside: avoid; margin: 0 0 5mm; padding: 2.5mm 3mm; border-radius: 2mm;
261
+ background: #fdf6f5; border-left: 3px solid ${BAD}; }
262
+ .failure h3 { margin: 0 0 1mm; }
263
+ .failure .idx { display: inline-block; min-width: 5mm; color: ${BAD}; font-weight: 700; }
264
+ .failure-where { font-size: 8pt; color: ${c('text-subtle', '#4d4d4d')}; margin: 0 0 2mm; }
265
+ .failure-err { margin: 0 0 2mm; padding: 2mm; background: #fff; border: 0.3pt solid #e6cdc9;
266
+ font-family: "DejaVu Sans Mono", monospace; font-size: 7.2pt; white-space: pre-wrap;
267
+ max-height: 60mm; overflow: hidden; }
268
+
269
+ /* ---- the full run ---- */
270
+ .feature { break-inside: auto; }
271
+ .feature-meta { font-size: 8pt; color: ${c('text-subtle', '#4d4d4d')}; margin: 0 0 2.5mm; }
272
+ .uri { font-family: "DejaVu Sans Mono", monospace; }
273
+ /* A scenario is the unit you read, so it is the unit that must not be split. */
274
+ .scenario { break-inside: avoid; margin: 0 0 3.5mm; padding-left: 3mm;
275
+ border-left: 2px solid ${c('subtle-brand', '#deedff')}; }
276
+ .scenario.failed { border-left-color: ${BAD}; }
277
+ .tags, .scenario-meta { font-size: 8pt; color: ${c('text-subtle', '#4d4d4d')}; margin: 0 0 1.5mm; }
278
+ .tags { font-family: "DejaVu Sans Mono", monospace; }
279
+ table.steps { width: 100%; border-collapse: collapse; margin: 1mm 0; }
280
+ table.steps td { padding: 0.8mm 1.5mm; vertical-align: top;
281
+ border-bottom: 0.3pt solid ${c('brand-light', '#ebe8e5')}; }
282
+ td.kw { width: 16mm; font-weight: 600; color: ${c('brand-secondary', '#2b447c')}; }
283
+ td.num { width: 18mm; text-align: right; color: ${c('text-subtle', '#4d4d4d')}; font-size: 8pt; }
284
+ tr.s-failed td { background: #fdf1f0; }
285
+ tr.s-skipped td, tr.s-undefined td { color: ${MUTED}; }
286
+ tr.err pre { margin: 1mm 0; padding: 2mm; background: #fdf1f0; border-left: 2px solid ${BAD};
287
+ font-family: "DejaVu Sans Mono", monospace; font-size: 7.2pt; white-space: pre-wrap; }
288
+ .pill { display: inline-block; font-size: 7pt; text-transform: uppercase; letter-spacing: .04em;
289
+ padding: 0.4mm 1.6mm; border-radius: 1mm; color: #fff; background: ${MUTED}; margin-right: 1.5mm; }
290
+ .pill.passed { background: ${OK}; } .pill.failed { background: ${BAD}; }
291
+ .pill.undefined { background: ${WARN}; }
292
+ .shot { margin: 2mm 0; break-inside: avoid; }
293
+ .shot img { max-width: 100%; max-height: 90mm; border: 1px solid ${c('brand-light', '#ebe8e5')}; }
294
+ .shot figcaption, .shot-note { font-size: 7.2pt; color: ${c('text-subtle', '#4d4d4d')}; }
295
+ .legend { font-size: 7.5pt; color: ${c('text-subtle', '#4d4d4d')}; margin: 0 0 5mm; }
296
+ .legend .key { display: inline-block; width: 2.5mm; height: 2.5mm; border-radius: .5mm;
297
+ margin: 0 1mm 0 3mm; vertical-align: middle; }
298
+ .foot { margin-top: 8mm; padding-top: 2mm; font-size: 7.5pt; color: ${c('text-subtle', '#4d4d4d')};
299
+ border-top: 1px solid ${c('brand-light', '#ebe8e5')}; }
300
+ </style></head><body>
301
+
302
+ <header class="cover">
303
+ <p class="eyebrow">Automated functional acceptance testing</p>
304
+ <h1>${esc(project)}</h1>
305
+ <p class="lede">${esc(key)} workspace · run of ${esc(humanTime(report.mtime))} · ${esc(humanDuration(totalNs))} of step time</p>
306
+ <span class="verdict ${verdict.replace(/\s/g, '')}">${verdict}</span>
307
+ </header>
308
+
309
+ <div class="summary-row">
310
+ <div class="donut-wrap">${donut(passed, failed, skipped, scenarios)}<span class="cap">pass rate</span></div>
311
+ <div class="summary">
312
+ <div class="tile brand"><span class="n">${scenarios}</span><span class="l">scenarios</span></div>
313
+ <div class="tile ok"><span class="n">${passed}</span><span class="l">passed</span></div>
314
+ <div class="tile ${failed ? 'bad' : ''}"><span class="n">${failed}</span><span class="l">failed</span></div>
315
+ <div class="tile"><span class="n">${skipped}</span><span class="l">skipped</span></div>
316
+ <div class="tile"><span class="n">${featureStats.length}</span><span class="l">features</span></div>
317
+ <div class="tile"><span class="n">${stepCount}</span><span class="l">steps</span></div>
318
+ </div>
319
+ </div>
320
+
321
+ <p class="legend"><span class="key" style="background:${OK}"></span>passed
322
+ <span class="key" style="background:${BAD}"></span>failed
323
+ <span class="key" style="background:${MUTED}"></span>skipped or undefined</p>
324
+
325
+ ${featureTable ? `<h2>Features at a glance</h2>${featureTable}` : ''}
326
+ ${failuresSection}
327
+
328
+ <h2>The full run</h2>
329
+ <p class="section-lede">Every scenario in the order it ran, with each step and how long it took.</p>
330
+ ${features || '<p>This report contains no scenarios.</p>'}
331
+
332
+ <p class="foot">${esc(report.rel)} · ${scenarios} scenarios · ${stepCount} steps · ${esc(humanDuration(totalNs))} of step time ·
333
+ rendered by the Webship Workspace dashboard. Screenshots of failures are embedded, so this file is complete on its own.</p>
334
+ </body></html>`;
335
+ }
336
+
337
+ module.exports = { testReportPdfHtml, humanDuration, statusOf };