@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.
- package/PLUGINS.md +249 -0
- package/README.md +379 -2
- package/WORKSPACES.md +69 -0
- package/actions/ai-items.js +293 -0
- package/actions/audio.js +48 -0
- package/actions/build-order.js +79 -0
- package/actions/checks.js +95 -0
- package/actions/claude-session.js +104 -0
- package/actions/commands-center.js +258 -0
- package/actions/composer.js +267 -0
- package/actions/ddev-addons.js +90 -0
- package/actions/ddev-mail.js +80 -0
- package/actions/ddev.js +237 -0
- package/actions/folders.js +165 -0
- package/actions/images-svg.js +33 -0
- package/actions/images.js +145 -0
- package/actions/navigation.js +190 -0
- package/actions/node.js +222 -0
- package/actions/notes.js +55 -0
- package/actions/plugins.js +36 -0
- package/actions/project-make.js +28 -0
- package/actions/rag.js +202 -0
- package/actions/row-actions.js +262 -0
- package/actions/screens.js +270 -0
- package/actions/testing.js +121 -0
- package/actions/video-edit.js +54 -0
- package/ai-inventory.js +182 -0
- package/ai-items.js +202 -0
- package/assistant-cast.js +176 -0
- package/assistant-suggestions.js +91 -0
- package/assistant.js +484 -0
- package/audio-voice.js +528 -0
- package/auth-google.js +260 -0
- package/build-ai.js +52 -0
- package/build-order-page.js +191 -0
- package/build-order.js +313 -0
- package/builder-args.js +117 -0
- package/changelog.js +302 -0
- package/chat-threads.js +134 -0
- package/checks.js +105 -0
- package/claude-args.js +282 -0
- package/claude-sessions.js +129 -0
- package/command-author.js +117 -0
- package/command-block.js +20 -0
- package/commands/defaults.yml +159 -0
- package/commands-page.js +460 -0
- package/commands.js +568 -0
- package/cucumber.js +43 -0
- package/ddev-addons-page.js +124 -0
- package/ddev-addons.js +154 -0
- package/ddev-custom-commands.js +96 -0
- package/ddev-domains.js +84 -0
- package/ddev-import.js +93 -0
- package/ddev-mail-page.js +167 -0
- package/ddev-mail.js +110 -0
- package/ddev-options.js +145 -0
- package/ddev-snapshots.js +126 -0
- package/ddev.js +145 -0
- package/docs/DEVELOPER-GUIDE.md +224 -0
- package/docs/README.md +252 -0
- package/docs/REFERENCE.md +684 -0
- package/docs/USER-GUIDE.md +697 -0
- package/docs/screenshots/assistant/avatar-board.png +0 -0
- package/docs/screenshots/assistant/avatar-coffee.png +0 -0
- package/docs/screenshots/assistant/avatar-colours.png +0 -0
- package/docs/screenshots/assistant/avatar-icons.png +0 -0
- package/docs/screenshots/assistant/avatar-moves.png +0 -0
- package/docs/screenshots/assistant/avatar-pick-cookieman.png +0 -0
- package/docs/screenshots/assistant/avatar-settings-voices.png +0 -0
- package/docs/screenshots/assistant/avatar-status.png +0 -0
- package/docs/screenshots/create-card/create-card.png +0 -0
- package/docs/screenshots/create-card/start-ai.png +0 -0
- package/docs/screenshots/guide-clone-address.png +0 -0
- package/docs/screenshots/guide-clone-worktree-off.png +0 -0
- package/docs/screenshots/guide-clone-worktree-on.png +0 -0
- package/docs/screenshots/guide-ddev-menu.png +0 -0
- package/docs/screenshots/guide-domains.png +0 -0
- package/docs/screenshots/guide-home.png +0 -0
- package/docs/screenshots/guide-ide-menu.png +0 -0
- package/docs/screenshots/guide-remove-refusal.png +0 -0
- package/docs/screenshots/guide-rows-worktree.png +0 -0
- package/docs/screenshots/guide-screens.png +0 -0
- package/docs/screenshots/guide-settings.png +0 -0
- package/docs/screenshots/guide-workspace-page.png +0 -0
- package/docs/screenshots/home/card-settings.png +0 -0
- package/docs/screenshots/home/drag-reorder.png +0 -0
- package/docs/screenshots/home/edit-card.png +0 -0
- package/docs/screenshots/images/claude-code-image.png +0 -0
- package/docs/screenshots/images/edit-with-ai.png +0 -0
- package/docs/screenshots/images/gallery.png +0 -0
- package/docs/screenshots/images/generate-description.png +0 -0
- package/docs/screenshots/images/generate-plan.png +0 -0
- package/docs/screenshots/images/providers.png +0 -0
- package/docs/screenshots/images/svg-form.png +0 -0
- package/docs/screenshots/images/svg-result.png +0 -0
- package/docs/screenshots/images/upload.png +0 -0
- package/docs/screenshots/new-folder/2-form.png +0 -0
- package/docs/screenshots/new-folder/8-ddev-options.png +0 -0
- package/docs/screenshots/new-folder/files-folder.png +0 -0
- package/docs/screenshots/projects/ddev-custom-commands.png +0 -0
- package/docs/screenshots/projects/state-chip-stop.png +0 -0
- package/docs/screenshots/readme/dashboard-dark.png +0 -0
- package/docs/screenshots/readme/dashboard-light.png +0 -0
- package/docs/screenshots/readme/dev-dark.png +0 -0
- package/docs/screenshots/readme/dev-light.png +0 -0
- package/docs/screenshots/readme/docs-dark.png +0 -0
- package/docs/screenshots/readme/docs-light.png +0 -0
- package/docs/screenshots/readme/icons/ai.svg +4 -0
- package/docs/screenshots/readme/icons/code.svg +4 -0
- package/docs/screenshots/readme/icons/docs.svg +4 -0
- package/docs/screenshots/readme/icons/services.svg +4 -0
- package/docs/screenshots/readme/icons/sites.svg +4 -0
- package/docs/screenshots/readme/test-report-dark.png +0 -0
- package/docs/screenshots/readme/test-report-light.png +0 -0
- package/docs/screenshots/readme/test-scenarios.png +0 -0
- package/docs/screenshots/readme/test-steps.png +0 -0
- package/docs/screenshots/readme/videos-dark.png +0 -0
- package/docs/screenshots/readme/videos-light.png +0 -0
- package/docs/screenshots/screens-menu.png +0 -0
- package/docs/screenshots/screens-new.png +0 -0
- package/docs/screenshots/settings/auth-google-lists.png +0 -0
- package/docs/screenshots/settings/delete-1-row.png +0 -0
- package/docs/screenshots/settings/delete-2-confirm.png +0 -0
- package/docs/screenshots/settings/delete-3-type-name.png +0 -0
- package/docs/screenshots/settings/delete-4-type-yes.png +0 -0
- package/event-log.js +59 -0
- package/files-browser.js +247 -0
- package/folders.js +121 -0
- package/format.js +73 -0
- package/git-info.js +81 -0
- package/github-auth.js +62 -0
- package/html.js +10 -0
- package/icons.js +83 -0
- package/images-ai.js +447 -0
- package/images-create.js +226 -0
- package/images-page.js +417 -0
- package/images-records.js +39 -0
- package/images-render.js +36 -0
- package/images-svg-core.js +214 -0
- package/images-svg-sanitize.js +188 -0
- package/images-svg.js +241 -0
- package/images.js +270 -0
- package/jobs.js +430 -0
- package/linger.js +21 -0
- package/lists.js +292 -0
- package/media-info.js +136 -0
- package/navigation-page.js +264 -0
- package/navigation.js +394 -0
- package/notes.js +0 -0
- package/package.json +47 -4
- package/palette-index.js +46 -0
- package/playwright.config.js +23 -0
- package/plugin-install.js +239 -0
- package/plugin-loader.js +466 -0
- package/plugin-manifest.js +83 -0
- package/plugin-settings.js +44 -0
- package/plugins/workspace-notes/index.js +59 -0
- package/plugins-page.js +103 -0
- package/project-make.js +241 -0
- package/project-name.js +210 -0
- package/public/assistant-avatar-moves.js +646 -0
- package/public/assistant-avatar-ui.js +1223 -0
- package/public/assistant-avatar.js +530 -0
- package/public/avatar/druplicon.vrm +0 -0
- package/public/css/base.css +260 -0
- package/public/css/components/ai-assistant.css +263 -0
- package/public/css/components/assistant-avatar.css +279 -0
- package/public/css/components/assistant-views.css +198 -0
- package/public/css/components/build-order.css +74 -0
- package/public/css/components/clone-as-a-git-worktree.css +30 -0
- package/public/css/components/command-palette.css +61 -0
- package/public/css/components/commands-center.css +139 -0
- package/public/css/components/confirm-dialog.css +40 -0
- package/public/css/components/create-card.css +8 -0
- package/public/css/components/ddev-menu.css +23 -0
- package/public/css/components/deep-chat.css +17 -0
- package/public/css/components/density.css +11 -0
- package/public/css/components/floating-assistant-workspace-pages.css +55 -0
- package/public/css/components/folders.css +5 -0
- package/public/css/components/full-height-left-assistant-sidebar-all-pages.css +39 -0
- package/public/css/components/full-height-right-actions-rail-pages-that-have-actions.css +267 -0
- package/public/css/components/grid-and-list.css +203 -0
- package/public/css/components/help-page.css +36 -0
- package/public/css/components/hero.css +16 -0
- package/public/css/components/images.css +70 -0
- package/public/css/components/layout-rhythm-and-spacing-polish.css +139 -0
- package/public/css/components/mic-on-any-field-that-wants-typing.css +416 -0
- package/public/css/components/modal-fit.css +113 -0
- package/public/css/components/page-width.css +23 -0
- package/public/css/components/plugins-page.css +9 -0
- package/public/css/components/project-cards.css +256 -0
- package/public/css/components/project-rows.css +390 -0
- package/public/css/components/reordering-the-workspace-cards.css +85 -0
- package/public/css/components/responsive-home-and-the-workspace-pages.css +457 -0
- package/public/css/components/running-commands-as-toasts.css +327 -0
- package/public/css/components/settings-editor-core-config-yml.css +828 -0
- package/public/css/components/short-results-as-a-toast-at-the-top.css +203 -0
- package/public/css/components/sound-player.css +84 -0
- package/public/css/components/suggest-agents-skills-commands.css +112 -0
- package/public/css/components/tab-modal.css +142 -0
- package/public/css/components/terminals.css +244 -0
- package/public/css/components/test-report-media-one-file-per-row-paged.css +144 -0
- package/public/css/components/top-toolbar-logo-breadcrumb.css +107 -0
- package/public/css/components/video-edit.css +6 -0
- package/public/css/components/web-ide-open-with.css +23 -0
- package/public/css/components/working-indicators.css +158 -0
- package/public/css/components/workspace-cards.css +79 -0
- package/public/favicon.ico +0 -0
- package/public/favicon.png +0 -0
- package/public/htmx.min.js +1 -0
- package/public/logo.png +0 -0
- package/public/media-player.js +215 -0
- package/public/mic.js +255 -0
- package/public/narration.js +110 -0
- package/public/palette.js +278 -0
- package/public/sortable.js +251 -0
- package/public/suggest.js +337 -0
- package/public/terminal.js +903 -0
- package/public/themes/default/theme.css +267 -0
- package/public/themes/ember/theme.css +180 -0
- package/public/themes/press/theme.css +290 -0
- package/public/themes/slate/theme.css +182 -0
- package/public/touch-icon.png +0 -0
- package/public/ui.js +2721 -0
- package/public/vendor/ace/LICENSE.txt +24 -0
- package/public/vendor/ace/ace.js +23 -0
- package/public/vendor/ace/ext-language_tools.js +8 -0
- package/public/vendor/ace/ext-searchbox.js +8 -0
- package/public/vendor/ace/mode-css.js +8 -0
- package/public/vendor/ace/mode-html.js +8 -0
- package/public/vendor/ace/mode-javascript.js +8 -0
- package/public/vendor/ace/mode-markdown.js +8 -0
- package/public/vendor/ace/mode-sh.js +8 -0
- package/public/vendor/ace/mode-yaml.js +8 -0
- package/public/vendor/ace/theme-textmate.js +8 -0
- package/public/vendor/ace/theme-tomorrow_night.js +8 -0
- package/public/vendor/ace/worker-yaml.js +1 -0
- package/public/vendor/deep-chat.LICENSE +21 -0
- package/public/vendor/deep-chat.bundle.js +1 -0
- package/public/vendor/fonts/dm-serif-text-LICENSE.txt +93 -0
- package/public/vendor/fonts/dm-serif-text-latin-400-italic.woff2 +0 -0
- package/public/vendor/fonts/dm-serif-text-latin-400-normal.woff2 +0 -0
- package/public/vendor/fonts/figtree-LICENSE.txt +93 -0
- package/public/vendor/fonts/figtree-latin-400-italic.woff2 +0 -0
- package/public/vendor/fonts/figtree-latin-400-normal.woff2 +0 -0
- package/public/vendor/fonts/figtree-latin-600-italic.woff2 +0 -0
- package/public/vendor/fonts/figtree-latin-600-normal.woff2 +0 -0
- package/public/vendor/fonts/jetbrains-mono-LICENSE.txt +93 -0
- package/public/vendor/fonts/jetbrains-mono-latin-400-normal.woff2 +0 -0
- package/public/vendor/fonts/ubuntu-mono-LICENSE.txt +98 -0
- package/public/vendor/fonts/ubuntu-mono-latin-wght-normal.woff2 +0 -0
- package/public/vendor/tabler-LICENSE.txt +21 -0
- package/public/vendor/tabler-sprite.svg +1 -0
- package/public/vendor/three/LICENSE +21 -0
- package/public/vendor/three/addons/loaders/GLTFLoader.js +4860 -0
- package/public/vendor/three/addons/utils/BufferGeometryUtils.js +1501 -0
- package/public/vendor/three/addons/utils/SkeletonUtils.js +496 -0
- package/public/vendor/three/three.core.min.js +6 -0
- package/public/vendor/three/three.module.min.js +6 -0
- package/public/vendor/three-vrm/LICENSE +22 -0
- package/public/vendor/three-vrm/three-vrm.module.min.js +983 -0
- package/public/vendor/uikit-icons.min.js +1 -0
- package/public/vendor/uikit.min.css +1 -0
- package/public/vendor/uikit.min.js +1 -0
- package/public/vendor/webship-icons/ui/bookmark-outlined.svg +1 -0
- package/public/vendor/webship-icons/ui/headset.svg +1 -0
- package/public/vendor/webship-icons/ui/menu.svg +1 -0
- package/public/vendor/webship-icons/ui/pause.svg +1 -0
- package/public/vendor/webship-icons/ui/play.svg +1 -0
- package/public/vendor/webship-icons/ui/share.svg +1 -0
- package/public/vendor/webship-icons/ui/sound-off.svg +1 -0
- package/public/vendor/webship-icons/ui/sound-on.svg +1 -0
- package/public/vendor/webship-icons/ui/stop.svg +1 -0
- package/public/vendor/xterm/addon-fit.js +2 -0
- package/public/vendor/xterm/addon-unicode11.js +2 -0
- package/public/vendor/xterm/addon-web-links.js +2 -0
- package/public/vendor/xterm/xterm.css +218 -0
- package/public/vendor/xterm/xterm.js +2 -0
- package/rag.js +169 -0
- package/reader.js +132 -0
- package/repo-blobs.js +66 -0
- package/report-pdf.js +337 -0
- package/row-layout.js +1439 -0
- package/screenrc +37 -0
- package/screens.js +1063 -0
- package/scripts/code-server-start.sh +190 -0
- package/scripts/images-claude-finish.js +107 -0
- package/scripts/screen-host.sh +114 -0
- package/scripts/screen-runner.js +362 -0
- package/scripts/screen-then.js +47 -0
- package/scripts/update.sh +350 -0
- package/server.js +7923 -0
- package/settings-file.js +423 -0
- package/speech.js +297 -0
- package/tab-modal.js +75 -0
- package/test-reports.js +260 -0
- package/themes.js +381 -0
- package/transcripts.js +168 -0
- package/urls.js +13 -0
- package/validate.js +18 -0
- package/version-check.js +94 -0
- package/video-edit.js +588 -0
- package/waiting.js +85 -0
- package/workspace-card-tools.js +41 -0
- package/workspace-delete.js +139 -0
- package/workspace-types.js +85 -0
- package/workspaces.js +553 -0
package/speech.js
ADDED
|
@@ -0,0 +1,297 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The assistant panel's voice settings, resolved into the objects deep-chat expects.
|
|
3
|
+
*
|
|
4
|
+
* The page used to be handed a flat bag of values and rebuild deep-chat's `speechToText` /
|
|
5
|
+
* `textToSpeech` objects itself, in a browser file nothing can require. That put the one part with
|
|
6
|
+
* real rules — which keys may be absent, which must never be empty — where it could only be checked
|
|
7
|
+
* by reading the source. The rules live here instead, the server sends the finished objects, and the
|
|
8
|
+
* page assigns them.
|
|
9
|
+
*
|
|
10
|
+
* The rules are not decoration. deep-chat silently ignores a key it does not recognise and silently
|
|
11
|
+
* ACCEPTS one it does, so both kinds of mistake are invisible at runtime:
|
|
12
|
+
*
|
|
13
|
+
* - `commands` built with empty strings arms voice commands nobody asked for. A blank `submit`
|
|
14
|
+
* phrase does not mean "no command"; it means a command that can trigger on almost anything.
|
|
15
|
+
* - `commands.settings.substrings` defaults to TRUE in deep-chat, so "submit the form" fires a
|
|
16
|
+
* `submit` command mid-sentence. It has to be turned off deliberately.
|
|
17
|
+
* - `webSpeech: true` accepts the browser's language. A recogniser left on the wrong one does not
|
|
18
|
+
* fail — it returns confident nonsense — so pinning it is the one setting that changes whether
|
|
19
|
+
* dictation works at all in a second language.
|
|
20
|
+
*
|
|
21
|
+
* Azure is the other backend deep-chat supports and is deliberately not offered: it needs a
|
|
22
|
+
* subscription key and sends the audio to a third party. Everything here stays on the machine, for
|
|
23
|
+
* the same reason the RAG index is local.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
'use strict';
|
|
27
|
+
|
|
28
|
+
/** Defaults, in one place, so the page and the resolver cannot disagree about them. */
|
|
29
|
+
const DEFAULTS = {
|
|
30
|
+
// What the assistant is CALLED, and what its composer says when the box is empty.
|
|
31
|
+
name: 'AI Assistant',
|
|
32
|
+
placeholder: '',
|
|
33
|
+
voiceInput: true,
|
|
34
|
+
voiceOutput: true,
|
|
35
|
+
submitAfterSilence: 2500,
|
|
36
|
+
speechRate: 1,
|
|
37
|
+
speechPitch: 1,
|
|
38
|
+
speechVolume: 1,
|
|
39
|
+
interimResults: true,
|
|
40
|
+
stopAfterSubmit: true,
|
|
41
|
+
speechLanguage: 'en-US',
|
|
42
|
+
voiceName: '',
|
|
43
|
+
// The assistant's avatar, shown from the robot icon in its panel's header: the one it starts as, one
|
|
44
|
+
// of `avatars`, or `none` for no avatar and no icon.
|
|
45
|
+
avatar: 'druplicon',
|
|
46
|
+
// The avatars that can be picked in the panel (assistant-cast.js finds each in the avatars workspace).
|
|
47
|
+
avatars: ['druplicon'],
|
|
48
|
+
// Where the assistant keeps what the team teaches it, relative to the workspace root.
|
|
49
|
+
memory: 'core/workspace-app/.memory',
|
|
50
|
+
// The Claude Code agent the assistant runs as; `none` runs it without one.
|
|
51
|
+
agent: 'webship-workspace',
|
|
52
|
+
};
|
|
53
|
+
const AVATARS = ['druplicon', 'none'];
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* The Workspace's default language, settings.yml `language:` (one of LANGUAGES, without `auto`): the
|
|
57
|
+
* language the assistant answers in, listens for and speaks. en-US when it is unset or not one we know.
|
|
58
|
+
*/
|
|
59
|
+
function workspaceLanguage(settings = {}) {
|
|
60
|
+
const v = String((settings && settings.language) || '').trim();
|
|
61
|
+
const known = LANGUAGES.find(([code]) => code !== 'auto' && code.toLowerCase() === v.toLowerCase());
|
|
62
|
+
return known ? known[0] : DEFAULTS.speechLanguage;
|
|
63
|
+
}
|
|
64
|
+
const languageName = (code) => (LANGUAGES.find(([c]) => c === code) || [code, code])[1];
|
|
65
|
+
|
|
66
|
+
// The names in `assistant.avatars:`, each a plain machine name; the shipped list when there are none.
|
|
67
|
+
const avatarList = (v) => {
|
|
68
|
+
const names = (Array.isArray(v) ? v : []).map((x) => String(x || '').trim().toLowerCase())
|
|
69
|
+
.filter((x, i, all) => /^[a-z0-9][a-z0-9_-]{0,62}$/.test(x) && x !== 'none' && all.indexOf(x) === i);
|
|
70
|
+
return names.length ? names : DEFAULTS.avatars;
|
|
71
|
+
};
|
|
72
|
+
const bool = (v, d) => (v === undefined || v === null ? d : v !== false && v !== 'false');
|
|
73
|
+
const num = (v, d) => (Number.isFinite(Number(v)) ? Number(v) : d);
|
|
74
|
+
const str = (v, d) => (typeof v === 'string' && v.trim() ? v.trim() : d);
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* "Not set", spelled as a word.
|
|
78
|
+
*
|
|
79
|
+
* An empty value in settings.yml (`speech_language: ""`) is unreadable — it looks like a setting
|
|
80
|
+
* somebody forgot to finish rather than one deliberately left to the browser, and it renders in the
|
|
81
|
+
* settings form as a blank box with no clue what belongs there. So the file says `auto`, `default` or
|
|
82
|
+
* `off`, and they all resolve to the same nothing here.
|
|
83
|
+
*/
|
|
84
|
+
// `false` and `no` belong here as well as the obvious words: parse_yaml hands values back as
|
|
85
|
+
// STRINGS, so `command_stop: false` arrives as "false" — which without this is a phrase, and the
|
|
86
|
+
// microphone would sit there listening for someone to say the word "false".
|
|
87
|
+
const UNSET = new Set(['auto', 'default', 'off', 'none', 'browser', 'false', 'no', '0', '']);
|
|
88
|
+
const optional = (v) => {
|
|
89
|
+
const t = str(v, '');
|
|
90
|
+
return UNSET.has(t.toLowerCase()) ? '' : t;
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The recogniser languages offered in the settings form.
|
|
95
|
+
*
|
|
96
|
+
* A short, deliberate list rather than the full BCP-47 registry: the browser accepts any tag, and a
|
|
97
|
+
* dropdown of nine hundred is worse than a text box. These are the ones this workspace is actually
|
|
98
|
+
* used in, with the Gulf and Levant Arabic variants — plus an empty
|
|
99
|
+
* first entry, because "follow the browser" is the right answer for most people and must stay
|
|
100
|
+
* reachable once the field is a select.
|
|
101
|
+
*
|
|
102
|
+
* A value that is not on the list is kept and shown as unknown rather than silently corrected: the
|
|
103
|
+
* settings file is the source of truth, and a form that quietly rewrites it is worse than one that
|
|
104
|
+
* admits it does not recognise a value.
|
|
105
|
+
*/
|
|
106
|
+
const LANGUAGES = [
|
|
107
|
+
['auto', 'Follow the browser'],
|
|
108
|
+
['en-US', 'English (United States)'],
|
|
109
|
+
['en-GB', 'English (United Kingdom)'],
|
|
110
|
+
['ar-JO', 'Arabic (Jordan)'],
|
|
111
|
+
['ar-SA', 'Arabic (Saudi Arabia)'],
|
|
112
|
+
['ar-AE', 'Arabic (United Arab Emirates)'],
|
|
113
|
+
['ar-EG', 'Arabic (Egypt)'],
|
|
114
|
+
['fr-FR', 'French (France)'],
|
|
115
|
+
['de-DE', 'German (Germany)'],
|
|
116
|
+
['es-ES', 'Spanish (Spain)'],
|
|
117
|
+
['tr-TR', 'Turkish (Türkiye)'],
|
|
118
|
+
];
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* What the plain field microphones need — the small mic beside every text input, which is a separate
|
|
122
|
+
* implementation from deep-chat's and predates it.
|
|
123
|
+
*
|
|
124
|
+
* It has to be a separate shape because those mics are raw `SpeechRecognition`, not deep-chat: they
|
|
125
|
+
* take a `lang` string, not a `webSpeech` object. What matters is that both come from THIS module,
|
|
126
|
+
* so `speech_language` reaches all of them. Before this, 36 field mics on the settings page alone
|
|
127
|
+
* were pinned to `document.documentElement.lang` and ignored the setting entirely — which is the
|
|
128
|
+
* confident-nonsense failure again, on most of the microphones in the app.
|
|
129
|
+
*/
|
|
130
|
+
function fieldMicConfig(cfg) {
|
|
131
|
+
if (!cfg.voiceInput) return false;
|
|
132
|
+
return {
|
|
133
|
+
// Empty means "let the browser decide", which is what SpeechRecognition does with an unset lang.
|
|
134
|
+
lang: cfg.speechLanguage || '',
|
|
135
|
+
interimResults: cfg.interimResults !== false,
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* What a voice command setting means.
|
|
141
|
+
*
|
|
142
|
+
* true / "true" listen for the command's own word ("stop", "submit", …)
|
|
143
|
+
* false / off no command — and `off` must keep working, because that is what shipped
|
|
144
|
+
* "any phrase" listen for that instead, which is the only way to have one in another language
|
|
145
|
+
*/
|
|
146
|
+
function commandPhrase(value, word) {
|
|
147
|
+
if (value === true || value === 'true' || value === 'yes' || value === 'on') return word;
|
|
148
|
+
return optional(value);
|
|
149
|
+
}
|
|
150
|
+
/**
|
|
151
|
+
* What the assistant is called here, and what its composer says when empty.
|
|
152
|
+
*
|
|
153
|
+
* Two levels, because one dashboard runs many workspaces: `assistant:` in `settings.yml` is the
|
|
154
|
+
* default — the home page and every page that belongs to no workspace — and `assistant:` in
|
|
155
|
+
* `workspace.<name>.settings.yml` overrides it for that workspace's page and its backups page.
|
|
156
|
+
* A folder whose assistant answers for Penpot instances can say so; the rest inherit.
|
|
157
|
+
*
|
|
158
|
+
* **One rule, applied at each level: blank, or one of the unset words, means "not set here".** A
|
|
159
|
+
* workspace that says nothing takes the global value; a global that says nothing takes the shipped
|
|
160
|
+
* default. It is the same merge `presentation:` already does, and it is what lets every workspace
|
|
161
|
+
* file ship the keys with `default` in them — visible and editable in the settings form, without
|
|
162
|
+
* twenty-four copies of the name quietly pinning it against a change made in one place.
|
|
163
|
+
*
|
|
164
|
+
* What that rule costs, stated rather than discovered: a workspace cannot silence a placeholder the
|
|
165
|
+
* global has set, because "say nothing" is how it inherits. Set the placeholder per workspace
|
|
166
|
+
* instead of globally if one of them needs to be silent.
|
|
167
|
+
*/
|
|
168
|
+
function assistantIdentity(settings = {}, wsSettings = null) {
|
|
169
|
+
const global = (settings && settings.assistant) || {};
|
|
170
|
+
const own = (wsSettings && wsSettings.assistant) || {};
|
|
171
|
+
const level = (key) => optional(own[key]) || optional(global[key]);
|
|
172
|
+
return {
|
|
173
|
+
name: level('name') || DEFAULTS.name,
|
|
174
|
+
placeholder: level('placeholder') || DEFAULTS.placeholder,
|
|
175
|
+
};
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/** The `assistant:` block of settings.yml, normalised. */
|
|
179
|
+
function assistantSettings(settings = {}) {
|
|
180
|
+
const a = settings.assistant || {};
|
|
181
|
+
return {
|
|
182
|
+
voiceInput: bool(a.voice_input, DEFAULTS.voiceInput),
|
|
183
|
+
voiceOutput: bool(a.voice_output, DEFAULTS.voiceOutput),
|
|
184
|
+
// 0 means "never send for me": deep-chat wants the key absent for that, not zero.
|
|
185
|
+
submitAfterSilence: num(a.submit_after_silence, DEFAULTS.submitAfterSilence),
|
|
186
|
+
speechRate: num(a.speech_rate, DEFAULTS.speechRate),
|
|
187
|
+
speechPitch: num(a.speech_pitch, DEFAULTS.speechPitch),
|
|
188
|
+
speechVolume: num(a.speech_volume, DEFAULTS.speechVolume),
|
|
189
|
+
// en-US ships as the default rather than 'follow the browser': most dictation here is
|
|
190
|
+
// English, and a browser set to something else silently recognised the wrong language.
|
|
191
|
+
// `auto` is still one dropdown entry away for anyone who wants the browser's choice.
|
|
192
|
+
// Unset, it is the Workspace's own language (settings.yml `language:`), so one line sets both.
|
|
193
|
+
speechLanguage: a.speech_language === undefined
|
|
194
|
+
? workspaceLanguage(settings)
|
|
195
|
+
: optional(a.speech_language),
|
|
196
|
+
voiceName: optional(a.voice_name),
|
|
197
|
+
avatars: avatarList(a.avatars),
|
|
198
|
+
avatar: a.avatar === 'none' ? 'none' : (avatarList(a.avatars).includes(String(a.avatar)) ? String(a.avatar) : avatarList(a.avatars)[0]),
|
|
199
|
+
agent: a.agent === undefined ? DEFAULTS.agent : (/^[a-z0-9][a-z0-9-]{0,62}$/.test(String(a.agent)) && String(a.agent) !== 'none' ? String(a.agent) : ''),
|
|
200
|
+
// A folder inside the workspace tree, never an absolute path or one that climbs out of it.
|
|
201
|
+
memory: typeof a.memory === 'string' && /^[\w.-]+(\/[\w.-]+)*$/.test(a.memory.trim()) && !/(^|\/)\.\.(\/|$)/.test(a.memory) ? a.memory.trim() : DEFAULTS.memory,
|
|
202
|
+
interimResults: bool(a.interim_results, DEFAULTS.interimResults),
|
|
203
|
+
stopAfterSubmit: bool(a.stop_after_submit, DEFAULTS.stopAfterSubmit),
|
|
204
|
+
// A voice command is an on/off choice, so the setting is a checkbox and the phrase is the
|
|
205
|
+
// command's own word: `command_stop: true` listens for "stop". A text box holding the word
|
|
206
|
+
// `off` was the wrong control for the question — it asked people to type a phrase when what
|
|
207
|
+
// they wanted was to turn something on.
|
|
208
|
+
//
|
|
209
|
+
// A STRING is still honoured, so anyone who wants a phrase of their own can write one. That
|
|
210
|
+
// matters for a second language: "stop" is not the word an Arabic speaker will say.
|
|
211
|
+
commands: {
|
|
212
|
+
submit: commandPhrase(a.command_submit, 'submit'),
|
|
213
|
+
stop: commandPhrase(a.command_stop, 'stop'),
|
|
214
|
+
pause: commandPhrase(a.command_pause, 'pause'),
|
|
215
|
+
resume: commandPhrase(a.command_resume, 'resume'),
|
|
216
|
+
removeAllText: commandPhrase(a.command_clear, 'clear'),
|
|
217
|
+
},
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
/**
|
|
222
|
+
* deep-chat's `speechToText`, or `false` to remove the microphone entirely rather than leave a
|
|
223
|
+
* button that does nothing.
|
|
224
|
+
*/
|
|
225
|
+
function speechToTextConfig(cfg) {
|
|
226
|
+
if (!cfg.voiceInput) return false;
|
|
227
|
+
|
|
228
|
+
// Only the commands that were actually given a phrase. An empty phrase is not a disabled command.
|
|
229
|
+
const commands = {};
|
|
230
|
+
for (const [key, phrase] of Object.entries(cfg.commands || {})) {
|
|
231
|
+
if (phrase) commands[key] = phrase;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
return {
|
|
235
|
+
webSpeech: cfg.speechLanguage ? { language: cfg.speechLanguage } : true,
|
|
236
|
+
displayInterimResults: cfg.interimResults !== false,
|
|
237
|
+
stopAfterSubmit: cfg.stopAfterSubmit !== false,
|
|
238
|
+
...(cfg.submitAfterSilence ? { submitAfterSilence: cfg.submitAfterSilence } : {}),
|
|
239
|
+
// Interim words are a guess the recogniser is still revising, so they are muted; the settled
|
|
240
|
+
// text is ordinary body colour. Tokens, not literals, so both themes follow with no second copy.
|
|
241
|
+
textColor: { interim: 'var(--text-subtle)', final: 'var(--text-default)' },
|
|
242
|
+
// The microphone in the design system rather than deep-chat's stock red, and command mode in the
|
|
243
|
+
// accent so an active listening mode is visible at a glance.
|
|
244
|
+
button: {
|
|
245
|
+
default: { container: { default: { color: 'var(--text-subtle)' } } },
|
|
246
|
+
active: { container: { default: { color: 'var(--danger, #d33)' } } },
|
|
247
|
+
commandMode: { container: { default: { color: 'var(--link)' } } },
|
|
248
|
+
},
|
|
249
|
+
...(Object.keys(commands).length
|
|
250
|
+
? { commands: { ...commands, settings: { substrings: false, caseSensitive: false } } }
|
|
251
|
+
: {}),
|
|
252
|
+
};
|
|
253
|
+
}
|
|
254
|
+
|
|
255
|
+
/**
|
|
256
|
+
* deep-chat's `textToSpeech`, or `false`. Its presence IS "read the replies aloud" — there is no
|
|
257
|
+
* separate switch, and `audio.autoPlay` in the docs is about responses carrying an audio file, which
|
|
258
|
+
* this assistant never returns.
|
|
259
|
+
*/
|
|
260
|
+
function textToSpeechConfig(cfg) {
|
|
261
|
+
if (!cfg.voiceOutput) return false;
|
|
262
|
+
return {
|
|
263
|
+
volume: cfg.speechVolume,
|
|
264
|
+
rate: cfg.speechRate,
|
|
265
|
+
pitch: cfg.speechPitch,
|
|
266
|
+
// A name the browser does not have falls back to its default rather than going silent. The lists
|
|
267
|
+
// differ per browser and per OS, which is why the setting is a plain string and not a menu.
|
|
268
|
+
...(cfg.voiceName ? { voiceName: cfg.voiceName } : {}),
|
|
269
|
+
// The recogniser's language is the right default for the reply too: ask in Arabic, be answered
|
|
270
|
+
// in an Arabic voice, without setting it twice.
|
|
271
|
+
...(cfg.speechLanguage ? { lang: cfg.speechLanguage } : {}),
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
/** What the page is handed: the flags it still needs, plus the two finished deep-chat objects. */
|
|
276
|
+
function panelConfig(settings = {}, wsSettings = null) {
|
|
277
|
+
const cfg = assistantSettings(settings);
|
|
278
|
+
return {
|
|
279
|
+
...assistantIdentity(settings, wsSettings),
|
|
280
|
+
voiceInput: cfg.voiceInput,
|
|
281
|
+
speechToText: speechToTextConfig(cfg),
|
|
282
|
+
textToSpeech: textToSpeechConfig(cfg),
|
|
283
|
+
// The narration toggle in the panel header. `voiceOutput` is only where it STARTS (settings.yml's
|
|
284
|
+
// voice_output; a choice made with the toggle is the browser's and wins). `narration` is the voice
|
|
285
|
+
// to use when it is on, always present: textToSpeech above is `false` when the setting is off, and
|
|
286
|
+
// someone who turns the toggle on still has to be spoken to in the voice they configured.
|
|
287
|
+
voiceOutput: cfg.voiceOutput,
|
|
288
|
+
narration: textToSpeechConfig({ ...cfg, voiceOutput: true }),
|
|
289
|
+
fieldMic: fieldMicConfig(cfg),
|
|
290
|
+
avatar: cfg.avatar,
|
|
291
|
+
};
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
module.exports = {
|
|
295
|
+
assistantSettings, assistantIdentity, speechToTextConfig, textToSpeechConfig, fieldMicConfig,
|
|
296
|
+
panelConfig, LANGUAGES, DEFAULTS, UNSET, AVATARS, workspaceLanguage, languageName,
|
|
297
|
+
};
|
package/tab-modal.js
ADDED
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A modal with its sections as tabs down the left, and the chosen one's content on the right.
|
|
3
|
+
*
|
|
4
|
+
* The editor modal used to be one scroll of everything, with its sections as links inside the page or
|
|
5
|
+
* as a back button and a list. A set of related views (all the settings, everything about a workitem)
|
|
6
|
+
* reads better as a set: the names stay in view, the one you are on is marked, and a link to it survives
|
|
7
|
+
* as `#<id>/<tab>`. Each tab is a fragment URL; the pane loads it into itself, so a tab nobody opens is
|
|
8
|
+
* never rendered.
|
|
9
|
+
*
|
|
10
|
+
* Tabs are real buttons in a tablist. Selection, the arrow keys, the filter and the address live in
|
|
11
|
+
* public/ui.js; this renders the markup and nothing else.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
const { esc } = require('./html');
|
|
15
|
+
const { iconHtml } = require('./icons');
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* @param {object} o
|
|
19
|
+
* @param {string} o.id names the modal in the address: #<id>/<tab>
|
|
20
|
+
* @param {string} o.title heading of the left column
|
|
21
|
+
* @param {string} [o.subtitle]
|
|
22
|
+
* @param {{label: string, url: string}} [o.back] a way out above the tabs, loaded into the whole modal
|
|
23
|
+
* @param {boolean} [o.filter] a filter box above the tabs, for a long list
|
|
24
|
+
* @param {boolean} [o.hash] keep #<id>/<tab> in the address, so the place can be linked to and reopened
|
|
25
|
+
* @param {{id: string, label: string, tabs: {id: string, label: string, url: string, icon?: string, hint?: string, badge?: string}[]}[]} o.groups
|
|
26
|
+
* @param {string} [o.active] the tab to open on; the first one when it is not found
|
|
27
|
+
*/
|
|
28
|
+
function tabModalHtml({ id, title, subtitle = '', back = null, filter = false, hash = false, groups, active = '' }) {
|
|
29
|
+
const all = groups.flatMap((g) => g.tabs);
|
|
30
|
+
if (!all.length) return '<div class="msg error">Nothing to show here.</div>';
|
|
31
|
+
const current = all.find((t) => t.id === active) || all[0];
|
|
32
|
+
const pane = `tm-${id}-pane`;
|
|
33
|
+
const tab = (t) => {
|
|
34
|
+
const on = t === current;
|
|
35
|
+
return `<button type="button" role="tab" class="tabmodal-tab${on ? ' is-selected' : ''}"
|
|
36
|
+
id="tm-${esc(id)}-${esc(t.id)}" data-tab="${esc(t.id)}" data-label="${esc(t.label)}"
|
|
37
|
+
data-hint="${esc(t.hint || '')}" aria-selected="${on ? 'true' : 'false'}" aria-controls="${esc(pane)}"
|
|
38
|
+
tabindex="${on ? '0' : '-1'}" hx-get="${esc(t.url)}" hx-target="#${esc(pane)}" hx-swap="innerHTML">
|
|
39
|
+
${iconHtml(t.icon || 'tabler:point', 0.9)}<span class="tabmodal-tab-label">${esc(t.label)}</span>${t.badge ? `<span class="tabmodal-badge">${esc(t.badge)}</span>` : ''}
|
|
40
|
+
</button>`;
|
|
41
|
+
};
|
|
42
|
+
return `
|
|
43
|
+
<div class="tabmodal" data-tabmodal="${esc(id)}"${hash ? ' data-hash' : ''}>
|
|
44
|
+
<nav class="tabmodal-nav" aria-label="${esc(title)}">
|
|
45
|
+
${back ? `<button type="button" class="tabmodal-back" hx-get="${esc(back.url)}" hx-target="#editor-modal-body" hx-swap="innerHTML">
|
|
46
|
+
<span uk-icon="icon: arrow-left; ratio: .8"></span> ${esc(back.label)}</button>` : ''}
|
|
47
|
+
<div class="tabmodal-title">
|
|
48
|
+
<h2>${esc(title)}</h2>
|
|
49
|
+
${subtitle ? `<span class="uk-text-meta">${esc(subtitle)}</span>` : ''}
|
|
50
|
+
</div>
|
|
51
|
+
${filter ? `<input type="search" class="tabmodal-filter uk-input uk-form-small" placeholder="Filter" aria-label="Filter ${esc(title)}" autocomplete="off">` : ''}
|
|
52
|
+
<div class="tabmodal-tabs" role="tablist" aria-orientation="vertical" aria-label="${esc(title)}">
|
|
53
|
+
${groups.map((g) => `
|
|
54
|
+
<div class="tabmodal-group" role="presentation" data-group="${esc(g.id)}">
|
|
55
|
+
${g.label ? `<p class="tabmodal-group-label" role="presentation">${esc(g.label)}</p>` : ''}
|
|
56
|
+
${g.tabs.map(tab).join('')}
|
|
57
|
+
</div>`).join('')}
|
|
58
|
+
</div>
|
|
59
|
+
</nav>
|
|
60
|
+
<section class="tabmodal-main">
|
|
61
|
+
<header class="tabmodal-head">
|
|
62
|
+
<h3 class="tabmodal-heading">${esc(current.label)}</h3>
|
|
63
|
+
<span class="tabmodal-sub uk-text-meta">${esc(current.hint || '')}</span>
|
|
64
|
+
</header>
|
|
65
|
+
<div class="tabmodal-pane" id="${esc(pane)}" role="tabpanel" aria-labelledby="tm-${esc(id)}-${esc(current.id)}"
|
|
66
|
+
tabindex="0" aria-live="polite">
|
|
67
|
+
<div class="tabmodal-loading" hx-get="${esc(current.url)}" hx-trigger="load" hx-target="#${esc(pane)}" hx-swap="innerHTML">
|
|
68
|
+
<span class="uk-text-meta">Loading…</span>
|
|
69
|
+
</div>
|
|
70
|
+
</div>
|
|
71
|
+
</section>
|
|
72
|
+
</div>`;
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
module.exports = { tabModalHtml };
|
package/test-reports.js
ADDED
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a run left behind: the report, the failure screenshots, the DOM dumps and the recordings.
|
|
3
|
+
*
|
|
4
|
+
* A project can carry more than one report — a Drupal tree has a `tests/reports/` per module that
|
|
5
|
+
* ships tests — so everything here is a list, and the row summarises the newest.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
const fs = require('fs');
|
|
9
|
+
const path = require('path');
|
|
10
|
+
|
|
11
|
+
const { esc } = require('./html');
|
|
12
|
+
const { humanSize, humanTime, VIDEO_RE } = require('./format');
|
|
13
|
+
const { workspaceDir } = require('./workspaces');
|
|
14
|
+
|
|
15
|
+
// A project's test report, if it ran the tests. webship-js (Playwright + Cucumber) writes
|
|
16
|
+
// <module>/tests/reports/cucumber_report.{html,json}, so the search is bounded to that
|
|
17
|
+
// shape rather than walking a built Drupal site — a project has ~20k files under
|
|
18
|
+
// web/core alone.
|
|
19
|
+
//
|
|
20
|
+
// The JSON is what the row and the modal read. The HTML is a cucumber-html-reporter page
|
|
21
|
+
// that pulls Bootstrap, jQuery and moment from CDNs, so on an offline box it renders
|
|
22
|
+
// unstyled and its collapsibles do nothing; the summary is built from the JSON instead and
|
|
23
|
+
// works with no network at all.
|
|
24
|
+
function reportLabel(r) {
|
|
25
|
+
const parts = r.rel.split(path.sep).filter((x) => x && x !== '.' && x !== 'tests' && x !== 'reports');
|
|
26
|
+
return parts.length ? parts[parts.length - 1] : 'this project';
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function testingStackOf(dir, project) {
|
|
30
|
+
const root = path.join(dir, project);
|
|
31
|
+
if (fs.existsSync(path.join(root, '.ddev', 'commands', 'web', 'init-full-automated-testing'))) return 'webship';
|
|
32
|
+
if (fs.existsSync(path.join(root, 'cucumber.js')) || fs.existsSync(path.join(root, 'tests', 'step-definitions'))) return 'webship-js';
|
|
33
|
+
return 'none';
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Can this project actually start a suite right now? Three things have to be
|
|
37
|
+
* true, and each has a different fix, so the row says which one is missing
|
|
38
|
+
* rather than only "tests": a suite to run, the JS dependencies installed, and
|
|
39
|
+
* the shared libraries chromium links against baked into the web image (a
|
|
40
|
+
* container without them fails every scenario at browser launch with
|
|
41
|
+
* "libnspr4.so: cannot open shared object file"). */
|
|
42
|
+
function testingReadiness(dir, project) {
|
|
43
|
+
const root = path.join(dir, project);
|
|
44
|
+
const stack = testingStackOf(dir, project);
|
|
45
|
+
const deps = fs.existsSync(path.join(root, 'node_modules', '@cucumber', 'cucumber'));
|
|
46
|
+
const libs = fs.existsSync(path.join(root, '.ddev', 'web-build', 'Dockerfile.playwright'));
|
|
47
|
+
const missing = [];
|
|
48
|
+
if (stack === 'none') missing.push('a testing stack');
|
|
49
|
+
if (!deps) missing.push('the JS dependencies');
|
|
50
|
+
if (!libs) missing.push('the browser libraries');
|
|
51
|
+
return { stack, deps, libs, ready: missing.length === 0, missing };
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
const REPORT_SEARCH_DIRS = ['packages', 'web/modules/custom', 'web/modules/contrib', 'tests', '.'];
|
|
55
|
+
|
|
56
|
+
function findTestReports(dir, project) {
|
|
57
|
+
const root = path.join(dir, project);
|
|
58
|
+
const found = [];
|
|
59
|
+
for (const sub of REPORT_SEARCH_DIRS) {
|
|
60
|
+
const base = path.join(root, sub);
|
|
61
|
+
let entries = [];
|
|
62
|
+
try { entries = fs.readdirSync(base, { withFileTypes: true }); } catch (_) { continue; }
|
|
63
|
+
// <sub>/<module>/tests/reports/, plus <sub>/tests/reports/ for sub === '.'
|
|
64
|
+
const candidates = entries.filter((e) => e.isDirectory()).map((e) => path.join(base, e.name, 'tests', 'reports'));
|
|
65
|
+
candidates.push(path.join(base, 'reports'));
|
|
66
|
+
for (const c of candidates) {
|
|
67
|
+
const html = path.join(c, 'cucumber_report.html');
|
|
68
|
+
const json = path.join(c, 'cucumber_report.json');
|
|
69
|
+
if (!fs.existsSync(json) && !fs.existsSync(html)) continue;
|
|
70
|
+
const rel = path.relative(root, c);
|
|
71
|
+
// A run leaves more than the report: webship-js writes a PNG and a DOM dump per
|
|
72
|
+
// failed step into tests/screenshots/, and a recording per scenario into
|
|
73
|
+
// tests/videos/. They are the evidence, so the modal links them rather than only
|
|
74
|
+
// saying a scenario failed.
|
|
75
|
+
const testsDir = path.dirname(c);
|
|
76
|
+
// Empty artifacts are dropped, not listed. A crashed run can leave a 0-byte recording
|
|
77
|
+
// or screenshot, and webship-js writes a DOM dump for every failed step even when the
|
|
78
|
+
// page never rendered — 81 of the 795 dumps in this tree are the literal empty
|
|
79
|
+
// document `<html><head></head><body></body></html>`, 11 of the 21 in the one report
|
|
80
|
+
// the test workspace shows. A link to a blank page is worse than no link.
|
|
81
|
+
//
|
|
82
|
+
// Judged exactly rather than by a size threshold: the smallest REAL dump here is 454
|
|
83
|
+
// bytes of captured JSON, so a "too small to matter" cutoff would discard real
|
|
84
|
+
// evidence. Only files small enough to be empty are opened, so this costs nothing on
|
|
85
|
+
// the ones that matter.
|
|
86
|
+
const isEmptyDocument = (full, size) => {
|
|
87
|
+
if (size > 400) return false;
|
|
88
|
+
try {
|
|
89
|
+
return /<body[^>]*>\s*<\/body>/i.test(fs.readFileSync(full, 'utf8'));
|
|
90
|
+
} catch (_) { return false; }
|
|
91
|
+
};
|
|
92
|
+
const sideFiles = (name, re) => {
|
|
93
|
+
try {
|
|
94
|
+
const base = path.join(testsDir, name);
|
|
95
|
+
return fs.readdirSync(base)
|
|
96
|
+
.filter((f) => re.test(f))
|
|
97
|
+
.map((f) => {
|
|
98
|
+
const full = path.join(base, f);
|
|
99
|
+
let size = 0, mtimeMs = 0;
|
|
100
|
+
try { const st = fs.statSync(full); size = st.size; mtimeMs = st.mtimeMs; } catch (_) { /* vanished */ }
|
|
101
|
+
return { file: path.relative(root, full), name: f, size, mtimeMs, full };
|
|
102
|
+
})
|
|
103
|
+
.filter((f) => !(/\.html?$/i.test(f.name)
|
|
104
|
+
? (f.size === 0 || isEmptyDocument(f.full, f.size))
|
|
105
|
+
: artifactIsEmpty(f.full, f.name, f.size, f.mtimeMs)))
|
|
106
|
+
.map(({ full, ...rest }) => rest)
|
|
107
|
+
.sort((a, b) => b.mtimeMs - a.mtimeMs || a.name.localeCompare(b.name));
|
|
108
|
+
} catch (_) { return []; }
|
|
109
|
+
};
|
|
110
|
+
found.push({ rel, html: fs.existsSync(html) ? path.relative(root, html) : null,
|
|
111
|
+
json: fs.existsSync(json) ? path.relative(root, json) : null,
|
|
112
|
+
mtime: fs.statSync(fs.existsSync(json) ? json : html).mtime,
|
|
113
|
+
shots: sideFiles('screenshots', /\.(png|jpe?g)$/i),
|
|
114
|
+
doms: sideFiles('screenshots', /\.html?$/i),
|
|
115
|
+
videos: sideFiles('videos', VIDEO_RE),
|
|
116
|
+
pdfs: [...sideFiles('reports', /\.pdf$/i), ...sideFiles('.', /\.pdf$/i)],
|
|
117
|
+
contrib: rel.includes(`web${path.sep}modules${path.sep}contrib`) });
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
// The copy under web/modules/contrib is the installed one; the source copy is the one
|
|
121
|
+
// to show when both exist, and they are usually byte-identical anyway.
|
|
122
|
+
const seen = new Set();
|
|
123
|
+
return found
|
|
124
|
+
.sort((a, b) => Number(a.contrib) - Number(b.contrib) || b.mtime - a.mtime)
|
|
125
|
+
.filter((r) => { const k = r.html || r.json; if (seen.has(path.basename(path.dirname(k)))) return false; return true; });
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
// Scenario tallies from the cucumber JSON. A scenario counts as failed if any step failed,
|
|
129
|
+
// skipped only if nothing in it ran — otherwise a partly-run scenario would be reported
|
|
130
|
+
// as a pass.
|
|
131
|
+
function testReportSummary(dir, project, report) {
|
|
132
|
+
if (!report || !report.json) return null;
|
|
133
|
+
let data;
|
|
134
|
+
try { data = JSON.parse(fs.readFileSync(path.join(dir, project, report.json), 'utf8')); } catch (_) { return null; }
|
|
135
|
+
if (!Array.isArray(data)) return null;
|
|
136
|
+
const out = { features: [], scenarios: 0, passed: 0, failed: 0, skipped: 0, mtime: report.mtime };
|
|
137
|
+
for (const feat of data) {
|
|
138
|
+
const f = { name: feat.name || '(unnamed)', scenarios: [] };
|
|
139
|
+
for (const sc of feat.elements || []) {
|
|
140
|
+
if (sc.type && sc.type !== 'scenario') continue;
|
|
141
|
+
const states = (sc.steps || []).map((st) => (st.result || {}).status);
|
|
142
|
+
const status = states.includes('failed') ? 'failed'
|
|
143
|
+
: states.some((x) => x === 'passed') ? 'passed' : 'skipped';
|
|
144
|
+
const ms = (sc.steps || []).reduce((n, st) => n + ((st.result || {}).duration || 0), 0) / 1e6;
|
|
145
|
+
f.scenarios.push({ name: sc.name || '(unnamed)', status, ms });
|
|
146
|
+
out.scenarios += 1;
|
|
147
|
+
out[status] += 1;
|
|
148
|
+
}
|
|
149
|
+
if (f.scenarios.length) out.features.push(f);
|
|
150
|
+
}
|
|
151
|
+
return out;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Both are looked up per project row, and rows re-render on every job poll.
|
|
155
|
+
let _reportCache = new Map();
|
|
156
|
+
let _reportCacheAt = 0;
|
|
157
|
+
function invalidateTestReports() {
|
|
158
|
+
_reportCache = new Map();
|
|
159
|
+
_reportCacheAt = 0;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
function testReportFor(dir, project) {
|
|
163
|
+
const now = Date.now();
|
|
164
|
+
if (now - _reportCacheAt > 2000) { _reportCache = new Map(); _reportCacheAt = now; }
|
|
165
|
+
const k = `${dir}/${project}`;
|
|
166
|
+
if (!_reportCache.has(k)) {
|
|
167
|
+
const reports = findTestReports(dir, project);
|
|
168
|
+
_reportCache.set(k, reports.length ? { report: reports[0], summary: testReportSummary(dir, project, reports[0]) } : null);
|
|
169
|
+
}
|
|
170
|
+
return _reportCache.get(k);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
// One page of a report's screenshots or recordings. Shared by the fragment route and by the
|
|
174
|
+
// delete action, so a deletion re-renders byte-identical markup instead of a second copy of it.
|
|
175
|
+
//
|
|
176
|
+
// The report modal loads these lazily and paged, because 303 inline <img> and 240 links made
|
|
177
|
+
// "Tests" a multi-minute load.
|
|
178
|
+
function testMediaHtml(key, project, kind, wantPage) {
|
|
179
|
+
const dir = workspaceDir(key);
|
|
180
|
+
const t = testReportFor(dir, project);
|
|
181
|
+
if (!t) return '<p class="uk-text-meta">No report.</p>';
|
|
182
|
+
const items = kind === 'videos' ? t.report.videos : t.report.shots; // already newest first
|
|
183
|
+
if (!items.length) {
|
|
184
|
+
// Reached both when a report never had any and when they have just been deleted.
|
|
185
|
+
return `<p class="uk-text-meta">No ${kind === 'videos' ? 'recordings' : 'screenshots'} in this report — a run writes its own.</p>`;
|
|
186
|
+
}
|
|
187
|
+
const PER = 15;
|
|
188
|
+
const pages = Math.max(1, Math.ceil(items.length / PER));
|
|
189
|
+
const page = Math.min(pages, Math.max(1, wantPage || 1));
|
|
190
|
+
const slice = items.slice((page - 1) * PER, page * PER);
|
|
191
|
+
const fileUrl = (rel) => `/files/${encodeURIComponent(key)}/${encodeURIComponent(project)}/${rel.split(path.sep).map(encodeURIComponent).join('/')}`;
|
|
192
|
+
const viewUrl = (name) => `/fragments/${encodeURIComponent(key)}/test-view/${encodeURIComponent(project)}?kind=${kind}&name=${encodeURIComponent(name)}`;
|
|
193
|
+
// A screenshot and its DOM dump share a stem, so the row links the page it was taken from.
|
|
194
|
+
const domFor = (name) => t.report.doms.find((d) => d.name.replace(/\.html?$/i, '') === name.replace(/\.(png|jpe?g)$/i, ''));
|
|
195
|
+
// Deleting asks in the confirm dialog, targets this list, and never takes a path: the action looks
|
|
196
|
+
// the name up in the report's own file list, exactly as the viewer does.
|
|
197
|
+
const del = (extra) => `hx-post="/actions/test-media-delete" hx-vals='{"workspace":"${esc(key)}","projectName":"${esc(project)}","kind":"${kind}",${extra},"confirm":"yes"}' hx-target="#test-media-${kind}" hx-swap="innerHTML" hx-trigger="confirmed-remove"`;
|
|
198
|
+
|
|
199
|
+
const rows = slice.map((f) => {
|
|
200
|
+
const failed = /^failed_/i.test(f.name) || /FAILED/i.test(f.name);
|
|
201
|
+
const dom = kind === 'shots' ? domFor(f.name) : null;
|
|
202
|
+
const label = esc(f.name.replace(/^failed_/i, '').replace(/\.(png|jpe?g|webm|mp4|mov|mkv|ogv)$/i, ''));
|
|
203
|
+
const meta = `${f.mtimeMs ? esc(humanTime(new Date(f.mtimeMs))) + ' · ' : ''}${esc(humanSize(f.size))}`;
|
|
204
|
+
const deleteOne = `<button type="button" class="uk-button uk-button-danger uk-button-small arm-step test-media-del"
|
|
205
|
+
title="${esc(`Delete ${f.name}${dom ? ' and its DOM dump' : ''}`)}" ${del(`"name":"${esc(f.name)}"`)}><span uk-icon="icon: trash; ratio: .6"></span></button>`;
|
|
206
|
+
if (kind === 'shots') {
|
|
207
|
+
return `
|
|
208
|
+
<li class="test-shot-card">
|
|
209
|
+
<button type="button" class="test-shot-thumb" title="${esc(f.name)}"
|
|
210
|
+
hx-get="${viewUrl(f.name)}" hx-target="#editor-modal-body" hx-swap="innerHTML">
|
|
211
|
+
<img src="${fileUrl(f.file)}" alt="${esc(f.name)}" loading="lazy">
|
|
212
|
+
</button>
|
|
213
|
+
<span class="test-shot-caption uk-text-meta" title="${esc(f.name)}">${label}</span>
|
|
214
|
+
<span class="test-shot-meta uk-text-meta">${meta}${failed ? ' <span class="uk-label uk-label-danger">failed</span>' : ''}
|
|
215
|
+
${dom ? `<a href="${fileUrl(dom.file)}" target="_blank" title="The DOM dump captured with this screenshot">DOM</a>` : ''}
|
|
216
|
+
<a href="${fileUrl(f.file)}" target="_blank" title="Open the raw file in a new tab"><span uk-icon="icon: link-external; ratio: .55"></span></a>
|
|
217
|
+
${deleteOne}</span>
|
|
218
|
+
</li>`;
|
|
219
|
+
}
|
|
220
|
+
return `
|
|
221
|
+
<li class="test-media-row">
|
|
222
|
+
<button type="button" class="test-media-name" title="${esc(f.name)}"
|
|
223
|
+
hx-get="${viewUrl(f.name)}" hx-target="#editor-modal-body" hx-swap="innerHTML">
|
|
224
|
+
<span uk-icon="icon: play-circle; ratio: .8"></span>
|
|
225
|
+
${label}
|
|
226
|
+
</button>
|
|
227
|
+
<span class="test-media-meta uk-text-meta">${meta}${failed ? ' <span class="uk-label uk-label-danger">failed</span>' : ''}</span>
|
|
228
|
+
<span class="test-media-actions">
|
|
229
|
+
<a class="uk-button uk-button-default uk-button-small" href="${fileUrl(f.file)}" target="_blank" title="Open the raw file in a new tab"><span uk-icon="icon: link-external; ratio: .6"></span></a>
|
|
230
|
+
${deleteOne}
|
|
231
|
+
</span>
|
|
232
|
+
</li>`;
|
|
233
|
+
}).join('');
|
|
234
|
+
|
|
235
|
+
const pageUrl = (n) => `/fragments/${encodeURIComponent(key)}/test-media/${encodeURIComponent(project)}?kind=${kind}&page=${n}`;
|
|
236
|
+
const pager = pages > 1 ? `
|
|
237
|
+
<div class="test-media-pager uk-text-meta">
|
|
238
|
+
<button type="button" class="uk-button uk-button-default uk-button-small" ${page <= 1 ? 'disabled' : `hx-get="${pageUrl(page - 1)}" hx-target="#test-media-${kind}" hx-swap="innerHTML"`}>‹</button>
|
|
239
|
+
<span>${(page - 1) * PER + 1}–${Math.min(page * PER, items.length)} of ${items.length} · page ${page} of ${pages} · newest first</span>
|
|
240
|
+
<button type="button" class="uk-button uk-button-default uk-button-small" ${page >= pages ? 'disabled' : `hx-get="${pageUrl(page + 1)}" hx-target="#test-media-${kind}" hx-swap="innerHTML"`}>›</button>
|
|
241
|
+
</div>` : '';
|
|
242
|
+
|
|
243
|
+
// Clearing the lot is one button, and it says how many and what else goes with them — the DOM
|
|
244
|
+
// dumps are written per failed step and are useless once their screenshot is gone.
|
|
245
|
+
const domCount = kind === 'shots' ? t.report.doms.length : 0;
|
|
246
|
+
const bulk = `
|
|
247
|
+
<div class="test-media-bulk">
|
|
248
|
+
<button type="button" class="uk-button uk-button-danger uk-button-small arm-step"
|
|
249
|
+
title="${esc(`Delete all ${items.length} ${kind === 'videos' ? 'recordings' : 'screenshots'}${domCount ? ` and ${domCount} DOM dumps` : ''} of this report`)}"
|
|
250
|
+
${del('"all":"yes"')}><span uk-icon="icon: trash; ratio: .7"></span> Delete all ${items.length} ${kind === 'videos' ? 'recordings' : 'screenshots'}${domCount ? ` + ${domCount} DOM dumps` : ''}</button>
|
|
251
|
+
<span class="uk-text-meta">Evidence from a finished run — the next run writes its own.</span>
|
|
252
|
+
</div>`;
|
|
253
|
+
|
|
254
|
+
return `${bulk}<ul class="${kind === 'shots' ? 'test-shot-grid' : 'test-media-list'}">${rows}</ul>${pager}`;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
module.exports = {
|
|
258
|
+
findTestReports, testReportSummary, testReportFor, invalidateTestReports, testMediaHtml,
|
|
259
|
+
reportLabel, testingStackOf, testingReadiness, REPORT_SEARCH_DIRS,
|
|
260
|
+
};
|