@webship.co/workspace 1.0.0-beta2 → 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (25) hide show
  1. package/core/workspace-app/PLUGINS.md +55 -2
  2. package/core/workspace-app/package.json +2 -2
  3. package/core/workspace-app/plugin-loader.js +85 -4
  4. package/core/workspace-app/plugins-page.js +1 -1
  5. package/core/workspace-app/public/ui.js +19 -3
  6. package/core/workspace-app/server.js +461 -276
  7. package/core/workspace-app/tests/features/assistant-speech.feature +2 -2
  8. package/core/workspace-app/tests/features/checks.feature +23 -6
  9. package/core/workspace-app/tests/features/clone-project.feature +5 -4
  10. package/core/workspace-app/tests/features/project-actions.feature +15 -8
  11. package/core/workspace-app/tests/step-definitions/assistant-speech.steps.js +10 -5
  12. package/core/workspace-app/tests/step-definitions/clone-project.steps.js +47 -0
  13. package/core/workspace-app/tests/step-definitions/preconditions.steps.js +69 -1
  14. package/core/workspace-app/tests/unit/changelog.test.js +49 -3
  15. package/core/workspace-app/tests/unit/claude-code-here.test.js +1 -1
  16. package/core/workspace-app/tests/unit/ddev-domains.test.js +31 -5
  17. package/core/workspace-app/tests/unit/plugin-system.test.js +38 -1
  18. package/core/workspace-app/tests/unit/preconditions.test.js +21 -0
  19. package/core/workspace-app/tests/unit/row-layout.test.js +4 -1
  20. package/core/workspace-app/tests/unit/row-menu-fragments.test.js +229 -0
  21. package/core/workspace-app/tests/unit/screens-menu.test.js +7 -2
  22. package/core/workspace-app/tests/unit/screens.test.js +35 -16
  23. package/core/workspace-app/tests/unit/video-borrow.test.js +27 -5
  24. package/core/workspace-app/tests/unit/video-edit.test.js +24 -5
  25. package/package.json +1 -1
@@ -57,7 +57,7 @@ Feature: Talking to the assistant
57
57
  # A browser with no synthesis voices accepts speak(), fires error: synthesis-failed, and is silent.
58
58
  # Nothing reported it, so "read replies aloud" looked switched on and did nothing at all — the worst
59
59
  # state a setting can be in.
60
- Scenario: The settings page says which voices this browser actually has
61
- Given I am on "/settings"
60
+ Scenario: The settings file editor says which voices this browser actually has
61
+ Given I am on "/settings/settings.yml"
62
62
  Then "[data-voice-name]" should be attached
63
63
  And the voice list should report what this browser has
@@ -3,37 +3,54 @@ Feature: The checks a project already declares
3
3
 
4
4
  # One quality menu, not two. On a packages workspace there is no browser suite at all, and on a
5
5
  # site there was no checks control — a separate button would have been the only one present in
6
- # each case. They live together under Tests, with the checks nested inside it.
6
+ # each case. They live together under Tests, with the checks nested inside it. The menu's items
7
+ # arrive when it opens (/fragments/row-menu/tests/…), so each scenario that reads one opens the
8
+ # first row's menu first; the caption under the button is on the page before that.
7
9
  Scenario: A package row offers its checks inside the Tests menu
8
10
  Given I am on "/products"
9
11
  Then the element ".project-row .act-menu-tests" should be displayed
10
- And ".act-menu-tests .act-menu-checks" should be attached
11
12
  And I should see "Tests"
13
+ When I click on the element ".project-row >> nth=0 >> .act-menu-tests button"
14
+ Then ".uk-dropdown.uk-open" should be visible
15
+ And ".act-menu-tests .act-menu-checks" should be attached
12
16
 
13
17
  Scenario: The button says what the package covers without running anything
14
18
  Given I am on "/products"
15
19
  Then the element ".act-menu-tests + .act-status" should be displayed
16
- And "[hx-post='/actions/checks-plan']" should be attached
20
+ When I click on the element ".project-row >> nth=0 >> .act-menu-tests button"
21
+ Then "[hx-post='/actions/checks-plan']" should be attached
17
22
 
18
23
  # The pair is the point: the canonical name is what you type (--only js), the code beside it is
19
24
  # the script the package itself declared. Listing them together is how the mapping is checkable.
25
+ # Opened on a row whose caption says the package declares checks, since a package that declares
26
+ # none lists none.
20
27
  Scenario: The nested menu lists each alias against the script it runs
21
28
  Given I am on "/products"
22
- Then ".checks-sub" should be attached
29
+ When I click on the element ".project-row:has(.act-menu-tests + .act-status:has-text('declared')), .project-row:has(.act-menu-tests + .act-status:has-text('deps missing')), .project-row:has(.act-menu-tests + .act-status:has-text('passed')), .project-row:has(.act-menu-tests + .act-status:has-text('failed')) >> nth=0 >> .act-menu-tests button"
30
+ Then ".uk-dropdown.uk-open" should be visible
31
+ And ".checks-sub" should be attached
23
32
  And ".checks-sub .checks-alias" should be attached
24
33
  And ".checks-sub .checks-script" should be attached
25
34
 
35
+ # Opened on a row whose caption says its checks are declared with their dependencies there — the
36
+ # alias control renders only for an alias that is READY, and "deps missing" says none is.
26
37
  @needs-check-aliases
27
38
  Scenario: Each alias can be run on its own, and so can all of them
28
39
  Given I am on "/products"
29
- Then "[hx-post='/actions/checks']" should be attached
40
+ When I click on the element ".project-row:has(.act-menu-tests + .act-status:has-text('declared')), .project-row:has(.act-menu-tests + .act-status:has-text('passed')), .project-row:has(.act-menu-tests + .act-status:has-text('failed')) >> nth=0 >> .act-menu-tests button"
41
+ Then ".uk-dropdown.uk-open" should be visible
42
+ And "[hx-post='/actions/checks']" should be attached
30
43
  And "[hx-vals*='only']" should be attached
31
44
 
32
45
  # A site gets the same nested list: its package.json declares the same vocabulary, and its
33
- # ddev-dev-tools add-on adds the PHP and Twig checks on top.
46
+ # ddev-dev-tools add-on adds the PHP and Twig checks on top. Opened on a row whose caption says a
47
+ # stack is there, since "Run the tests" is only offered where there is a suite to run.
48
+ @needs-testing-stack
34
49
  Scenario: A site keeps its testing verbs and gains the checks beside them
35
50
  Given I am on "/dev"
36
51
  Then the element ".project-row .act-menu-tests" should be displayed
52
+ When I click on the element ".project-row:has(.act-menu-tests + .act-status:text-is('ready to test')), .project-row:has(.act-menu-tests + .act-status:text-is('not ready')), .project-row:has(.act-menu-tests + .act-status:text-is('running now')) >> nth=0 >> .act-menu-tests button"
53
+ Then ".uk-dropdown.uk-open" should be visible
37
54
  And "[hx-post='/actions/testing-configure']" should be attached
38
55
  And "[hx-post='/actions/testing-run']" should be attached
39
56
  And ".act-menu-tests .act-menu-checks" should be attached
@@ -4,17 +4,18 @@ Feature: Cloning a project
4
4
 
5
5
  Scenario: The DDEV menu offers the clone
6
6
  Given I am on "/dev"
7
+ When I click on the element ".project-row >> nth=0 >> .act-menu-ddev button"
7
8
  Then "[hx-get^='/fragments/projects/clone-form/dev/']" should be attached
8
9
 
9
10
  Scenario: The form arrives prefilled, with the live check wired
10
- Given I am on "/fragments/projects/clone-form/dev/v11x00b1i18n"
11
- Then "input[name='to'][value='v11x00b1i18n-copy']" should be attached
11
+ When I open the clone form of the first project in "dev"
12
+ Then the clone form proposes a copy of that project
12
13
  And "select[name='toWorkspace']" should be attached
13
14
  And "[hx-get='/fragments/projects/clone-check']" should be attached
14
15
  And "textarea[name='prompt']" should be attached
15
16
 
16
17
  Scenario: The live check refuses an existing project
17
- Given I am on "/fragments/projects/clone-check?to=v11x00b1i18n&toWorkspace=dev"
18
+ When I ask the live check about the name of the first project in "dev"
18
19
  Then I should see "already exists"
19
20
 
20
21
  Scenario: The live check blesses a free name
@@ -22,7 +23,7 @@ Feature: Cloning a project
22
23
  Then I should see "is free"
23
24
 
24
25
  Scenario: Cloning over an existing project is refused server-side too
25
- When I post "/actions/clone-project" with '{"workspace":"dev","projectName":"v11x00b1i18n","to":"v11x00b1i18n","toWorkspace":"dev"}'
26
+ When I post a clone of the first project in "dev" over itself
26
27
  Then the action response should contain "already exists"
27
28
 
28
29
  Scenario: A source that does not exist is refused
@@ -2,9 +2,10 @@ Feature: What a project row can actually do
2
2
  The verbs on a row, asserted where they are rather than by opening every menu.
3
3
 
4
4
  # Each menu carries the name of the verb it opens (act-menu-ddev, act-menu-tests, …). A page of
5
- # thirteen projects has thirteen of each, so these assert that the control and its items EXIST
6
- # rather than clicking one — a test that has to say "the third one" breaks the moment a project
7
- # is built. The one click test lives on /rag, which holds exactly one row.
5
+ # thirteen projects has thirteen of each, so these assert that the control EXISTS rather than
6
+ # clicking one — a test that has to say "the third one" breaks the moment a project is built. A
7
+ # menu's items are not on the page until it opens (its body comes from /fragments/row-menu/…), so
8
+ # a scenario that needs an item opens one menu first, by `nth=0` or on /rag, which holds one row.
8
9
 
9
10
  Scenario: A row carries a menu for each thing it can do
10
11
  Given I am on "/dev"
@@ -14,10 +15,16 @@ Feature: What a project row can actually do
14
15
  And the element ".project-row .act-menu-more" should be displayed
15
16
 
16
17
  # These two are the whole testing feature as far as the interface is concerned: one sets the
17
- # environment up, the other runs the suite. Both post to their own action.
18
+ # environment up, the other runs the suite. Both post to their own action. The menu's items arrive
19
+ # when it opens (/fragments/row-menu/tests/…), so the menu is opened first — on a row whose caption
20
+ # says a stack is there (ready to test, not ready, running now), since "Run the tests" is only
21
+ # offered where there is a suite to run.
22
+ @needs-testing-stack
18
23
  Scenario: The Tests menu offers setting the environment up and running the suite
19
24
  Given I am on "/dev"
20
- Then "[hx-post='/actions/testing-configure']" should be attached
25
+ When I click on the element ".project-row:has(.act-menu-tests + .act-status:text-is('ready to test')), .project-row:has(.act-menu-tests + .act-status:text-is('not ready')), .project-row:has(.act-menu-tests + .act-status:text-is('running now')) >> nth=0 >> .act-menu-tests button"
26
+ Then ".uk-dropdown.uk-open" should be visible
27
+ And "[hx-post='/actions/testing-configure']" should be attached
21
28
  And "[hx-post='/actions/testing-run']" should be attached
22
29
 
23
30
  # The state under the button is the answer to "is there anything to look at yet" — it says
@@ -34,7 +41,7 @@ Feature: What a project row can actually do
34
41
  And I should see "Describe (URLs, services)"
35
42
 
36
43
  # Launch belongs with the hostname it opens, not in the row of things you DO to a project.
37
- Scenario: A running project offers its own URL beside the Launch button
44
+ @needs-running-project
45
+ Scenario: A running project shows its own host on its card
38
46
  Given I am on "/dev"
39
- Then the element ".proj-launch-line" should be displayed
40
- And the element ".proj-launch-line .proj-alias" should be displayed
47
+ Then ".project-row .pcard-host-name" should be attached
@@ -39,12 +39,17 @@ Then(/^the assistant should offer voice input$/, async function () {
39
39
  });
40
40
 
41
41
  Then(/^the assistant should read its replies aloud$/, async function () {
42
- const cfg = await speechConfig(this.page);
42
+ // The replies are read by the panel's own narrator, not by deep-chat (whose textToSpeech the panel
43
+ // sets to false on purpose: it would speak the HTML). What must hold is that the panel was handed
44
+ // narration that is switched on and resolved: a voice with a rate.
45
+ const cfg = await this.page.evaluate(() => {
46
+ const el = document.querySelector('deep-chat');
47
+ if (!el) return null;
48
+ try { return JSON.parse(el.dataset.settings || '{}'); } catch (_) { return {}; }
49
+ });
43
50
  assert.ok(cfg, 'there is no deep-chat element on this page');
44
- assert.notStrictEqual(cfg.textToSpeech, 'unset', 'deep-chat was never given a textToSpeech config');
45
- assert.notStrictEqual(cfg.textToSpeech, false, 'voice output is switched off');
46
- // Its presence IS the read-aloud behaviour; there is no separate switch to check.
47
- assert.ok(Number.isFinite(cfg.textToSpeech.rate), 'no speech rate, so the config is not the resolved one');
51
+ assert.notStrictEqual(cfg.voiceOutput, false, 'voice output is switched off');
52
+ assert.ok(cfg.narration && Number.isFinite(cfg.narration.rate), 'no narration voice with a speech rate, so the config is not the resolved one');
48
53
  });
49
54
 
50
55
  /**
@@ -0,0 +1,47 @@
1
+ /**
2
+ * The clone action, exercised on whatever project the dev workspace holds first, so the scenarios
3
+ * name no project of one machine (#89). The project is read from the tree the dashboard serves.
4
+ */
5
+
6
+ 'use strict';
7
+
8
+ const fs = require('fs');
9
+ const path = require('path');
10
+ const { Given, When, Then } = require('@cucumber/cucumber');
11
+ const assert = require('assert');
12
+
13
+ const APP = path.join(__dirname, '..', '..');
14
+ const BASE = () => process.env.LAUNCH_URL || 'https://workspace.ddev.site';
15
+
16
+ function firstProject(wsKey) {
17
+ const { loadWorkspaces } = require(path.join(APP, 'workspaces'));
18
+ const dir = loadWorkspaces()[wsKey].dir;
19
+ const first = fs.readdirSync(dir).filter((p) => !p.startsWith('.') && fs.statSync(path.join(dir, p)).isDirectory()).sort()[0];
20
+ assert.ok(first, `the ${wsKey} workspace holds no project to clone`);
21
+ return first;
22
+ }
23
+
24
+ Given('{string} is the first project of the {string} workspace', function (alias, wsKey) {
25
+ this.cloneProject = firstProject(wsKey); this.cloneWorkspace = wsKey;
26
+ });
27
+
28
+ When('I open the clone form of the first project in {string}', async function (wsKey) {
29
+ this.cloneProject = firstProject(wsKey); this.cloneWorkspace = wsKey;
30
+ await this.page.goto(`${BASE()}/fragments/projects/clone-form/${wsKey}/${encodeURIComponent(this.cloneProject)}`, { waitUntil: 'domcontentloaded' });
31
+ });
32
+
33
+ Then('the clone form proposes a copy of that project', async function () {
34
+ await this.page.locator(`input[name='to'][value='${this.cloneProject}-copy']`).waitFor({ state: 'attached', timeout: 10000 });
35
+ });
36
+
37
+ When('I ask the live check about the name of the first project in {string}', async function (wsKey) {
38
+ this.cloneProject = firstProject(wsKey); this.cloneWorkspace = wsKey;
39
+ await this.page.goto(`${BASE()}/fragments/projects/clone-check?to=${encodeURIComponent(this.cloneProject)}&toWorkspace=${wsKey}`, { waitUntil: 'domcontentloaded' });
40
+ });
41
+
42
+ When('I post a clone of the first project in {string} over itself', async function (wsKey) {
43
+ this.cloneProject = firstProject(wsKey); this.cloneWorkspace = wsKey;
44
+ const form = { workspace: wsKey, projectName: this.cloneProject, to: this.cloneProject, toWorkspace: wsKey };
45
+ const response = await this.page.request.post(`${BASE()}/actions/clone-project`, { form });
46
+ this.lastActionResponse = await response.text();
47
+ });
@@ -97,4 +97,72 @@ Before({ tags: '@needs-check-aliases' }, function () {
97
97
  return undefined;
98
98
  });
99
99
 
100
- module.exports = { settingValue, APP };
100
+ Before({ tags: '@needs-testing-stack' }, function () {
101
+ // A row offers "Run the tests" only when the project has a testing stack (a webship-js project,
102
+ // or the init-full-automated-testing command); the Tests menu of a bare site has nothing to run.
103
+ let any = false;
104
+ try {
105
+ const { testingStackOf } = require(path.join(APP, 'test-reports'));
106
+ const { loadWorkspaces } = require(path.join(APP, 'workspaces'));
107
+ const dir = loadWorkspaces().dev.dir;
108
+ any = fs.readdirSync(dir).some((p) => {
109
+ try { return fs.statSync(path.join(dir, p)).isDirectory() && testingStackOf(dir, p) !== 'none'; } catch (_) { return false; }
110
+ });
111
+ } catch (_) { any = false; }
112
+ if (!any) {
113
+ this.attach('SKIPPED: no project in dev/ has a testing stack (a cucumber.js, tests/step-definitions, '
114
+ + 'or the init-full-automated-testing command), so no row offers "Run the tests". Set one up and '
115
+ + 'this becomes a real test again.');
116
+ return 'skipped';
117
+ }
118
+ return undefined;
119
+ });
120
+
121
+ Before({ tags: '@needs-running-project' }, function () {
122
+ // The card shows a project's host only while DDEV reports it running.
123
+ let any = false;
124
+ try {
125
+ const { execFileSync } = require('child_process');
126
+ const { loadWorkspaces } = require(path.join(APP, 'workspaces'));
127
+ const dir = fs.realpathSync(loadWorkspaces().dev.dir);
128
+ const out = JSON.parse(execFileSync('ddev', ['list', '--json-output'], { encoding: 'utf8', timeout: 60000 }));
129
+ any = (out.raw || []).some((p) => p.status === 'running' && String(p.approot || '').startsWith(dir + path.sep));
130
+ } catch (_) { any = false; }
131
+ if (!any) {
132
+ this.attach('SKIPPED: no project in dev/ is running (ddev list), so no card shows a host. Start one '
133
+ + 'and this becomes a real test again.');
134
+ return 'skipped';
135
+ }
136
+ return undefined;
137
+ });
138
+
139
+ /** A scalar out of a plugin's own settings file (core/config/plugin.<name>.settings.yml), or ''. */
140
+ function pluginSettingValue(plugin, key) {
141
+ try {
142
+ const yml = fs.readFileSync(path.join(ROOT, 'core', 'config', `plugin.${plugin}.settings.yml`), 'utf8');
143
+ const m = yml.match(new RegExp(`^\\s*${key}:\\s*(.+)$`, 'm'));
144
+ return m ? m[1].trim().replace(/^["']|["']$/g, '').replace(/\s+#.*$/, '') : '';
145
+ } catch (_) { return ''; }
146
+ }
147
+
148
+ // A plugin ships features into this suite but no hooks, so it states a precondition in its tag:
149
+ // `@needs-setting-<plugin>-<key>` skips the scenario, with the reason, while that key has no value in
150
+ // the plugin's settings file. The plugin name ends at the first hyphen that is followed by the key,
151
+ // so a plugin named with hyphens tags `@needs-setting-google-drive-folder_id` and the key is the part
152
+ // after the last plugin-name hyphen: the key itself never contains a hyphen (settings keys are snake_case).
153
+ const NEEDS_SETTING = /^@needs-setting-(.+)-([a-z0-9_]+)$/;
154
+ Before(function (scenario) {
155
+ for (const t of scenario.pickle.tags || []) {
156
+ const m = NEEDS_SETTING.exec(t.name);
157
+ if (!m) continue;
158
+ const [, plugin, key] = m;
159
+ if (!pluginSettingValue(plugin, key)) {
160
+ this.attach(`SKIPPED: core/config/plugin.${plugin}.settings.yml has no ${key} on this machine; `
161
+ + `set it and this becomes a real test again.`);
162
+ return 'skipped';
163
+ }
164
+ }
165
+ return undefined;
166
+ });
167
+
168
+ module.exports = { settingValue, pluginSettingValue, APP };
@@ -8,13 +8,53 @@
8
8
 
9
9
  'use strict';
10
10
 
11
- const test = require('node:test');
11
+ const { test, before, after } = require('node:test');
12
12
  const assert = require('node:assert');
13
+ const fs = require('fs');
14
+ const os = require('os');
15
+ const path = require('path');
16
+ const { copyConfig } = require('./_isolated-config');
17
+
18
+ // The walk is tested on a tree built here, not on whatever this checkout holds: a fresh clone has no
19
+ // built site and no changelog in it, and a test that passed only on a full machine said nothing about
20
+ // the code. The module reads its root once, at load, so the root is set before it is required.
21
+ const REPO = path.join(__dirname, '../../../..');
22
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ws-changelog-root-'));
23
+ process.env.WORKSPACE_ROOT = root;
13
24
 
14
25
  const {
15
26
  parseChangelog, kindOf, looksLikeVersion, listChangelogs, changelogFor, releasesFor, changelogIndex,
16
27
  } = require('../../changelog');
17
28
 
29
+ const write = (rel, text) => {
30
+ fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true });
31
+ fs.writeFileSync(path.join(root, rel), text);
32
+ };
33
+
34
+ before(() => {
35
+ fs.mkdirSync(path.join(root, 'core'), { recursive: true });
36
+ copyConfig(path.join(REPO, 'core/config'), path.join(root, 'core/config'));
37
+ // A product with a Keep a Changelog file (Unreleased on top, as they are written), a built site in
38
+ // dev/ with the old Drupal .txt, and a project that keeps none.
39
+ write('products/starter/CHANGELOG.md', `# Changelog
40
+
41
+ ## [Unreleased]
42
+ ### Added
43
+ - Something that is not out yet
44
+
45
+ ## [1.0.3] - 2026-09-14
46
+ ### Added
47
+ - Add a Newsletter Canvas page at \`/newsletter\`
48
+ `);
49
+ write('dev/blog1/CHANGELOG.txt', `Blog 7.x-1.1, 2012-02-22
50
+ ------------------------
51
+ - #1 by someone: a fix
52
+ `);
53
+ write('dev/plain/README.md', 'No changelog here.\n');
54
+ });
55
+
56
+ after(() => fs.rmSync(root, { recursive: true, force: true }));
57
+
18
58
  /* ---------------- the formats ---------------- */
19
59
 
20
60
  test('Keep a Changelog: version, date, and the kind from the section', () => {
@@ -138,7 +178,7 @@ test('an empty file, and a file of nothing but prose, are no releases rather tha
138
178
  assert.deepEqual(parseChangelog('# Changelog\n\nAll notable changes are documented here.\n'), []);
139
179
  });
140
180
 
141
- /* ---------------- against the real tree ---------------- */
181
+ /* ---------------- against the tree built above ---------------- */
142
182
 
143
183
  test('a changelog anywhere in the tree is found, not only in products/', () => {
144
184
  const found = listChangelogs();
@@ -151,7 +191,9 @@ test('a changelog anywhere in the tree is found, not only in products/', () => {
151
191
  // The point of the change: it is a walk of every workspace, not a list of one.
152
192
  const workspaces = new Set(found.map((c) => c.workspace));
153
193
  assert.ok(workspaces.size > 1,
154
- `only ${[...workspaces]} was walked — a built site in dev/ keeps one too`);
194
+ `only ${[...workspaces]} was walked, and a built site in dev/ keeps one too`);
195
+ assert.deepEqual(found.map((c) => `${c.workspace}/${c.item}`).sort(), ['dev/blog1', 'products/starter'],
196
+ 'both files, and nothing for the project that keeps none');
155
197
  });
156
198
 
157
199
  test('a name that climbs out of its workspace is refused', () => {
@@ -174,4 +216,8 @@ test('the index summarises everything without throwing on any of it', () => {
174
216
  // The newest release is the first in the file, never "Unreleased" — a version people can install.
175
217
  assert.ok(!/^unreleased$/i.test(r.latest), `${r.label} reports Unreleased as its latest`);
176
218
  }
219
+ const starter = rows.find((r) => r.item === 'starter');
220
+ assert.equal(starter.latest, '1.0.3', 'the Unreleased block on top is passed over');
221
+ assert.equal(starter.unreleasedEntries, 1);
222
+ assert.equal(rows.find((r) => r.item === 'blog1').latest, 'Blog 7.x-1.1');
177
223
  });
@@ -6,7 +6,7 @@ const path = require('path');
6
6
  const server = fs.readFileSync(path.join(__dirname, '..', '..', 'server.js'), 'utf8');
7
7
 
8
8
  test('the SCREENS menu offers Claude Code in the same folder, beside New screen here', () => {
9
- assert.match(server, /screen-new-form\?workspace=\$\{encodeURIComponent\(sm\[1\]\)\}&amp;project=\$\{encodeURIComponent\(sm\[2\]\)\}&amp;kind=claude/);
9
+ assert.match(server, /screen-new-form\?workspace=\$\{encodeURIComponent\(ws\)\}&amp;project=\$\{encodeURIComponent\(project\)\}&amp;kind=claude/);
10
10
  assert.match(server, /Claude Code here…/);
11
11
  });
12
12
 
@@ -9,14 +9,39 @@
9
9
 
10
10
  'use strict';
11
11
 
12
- const test = require('node:test');
12
+ const { test, before, after } = require('node:test');
13
13
  const assert = require('node:assert');
14
14
  const fs = require('fs');
15
15
  const os = require('os');
16
16
  const path = require('path');
17
+ const { copyConfig } = require('./_isolated-config');
18
+
19
+ // The walk over the workspaces runs on a tree built here, not on this checkout: a fresh clone holds no
20
+ // project at all. The workspaces module reads its root once, at load, so it is set before the require.
21
+ const REPO = path.join(__dirname, '../../../..');
22
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ws-ddev-domains-root-'));
23
+ process.env.WORKSPACE_ROOT = root;
17
24
 
18
25
  const { ddevHostnames, ddevExtraDomains } = require('../../ddev-domains');
19
- const { ROOT } = require('../../workspaces');
26
+ const { loadWorkspaces } = require('../../workspaces');
27
+
28
+ /** A DDEV project inside a workspace of the built tree. */
29
+ function inTree(workspace, name, yaml) {
30
+ const dir = path.join(root, workspace, name);
31
+ fs.mkdirSync(path.join(dir, '.ddev'), { recursive: true });
32
+ if (yaml !== null) fs.writeFileSync(path.join(dir, '.ddev', 'config.yaml'), yaml);
33
+ }
34
+
35
+ before(() => {
36
+ fs.mkdirSync(path.join(root, 'core'), { recursive: true });
37
+ copyConfig(path.join(REPO, 'core/config'), path.join(root, 'core/config'));
38
+ inTree('dev', 'blog', 'name: blog\nadditional_fqdns:\n - storybook.blog.ddev.site\n');
39
+ inTree('dev', 'plain', 'name: plain\nadditional_hostnames:\n - plain-dev\n');
40
+ inTree('products', 'shop', 'name: shop\nadditional_fqdns:\n - mail.shop.ddev.site\n');
41
+ inTree('products', 'bare', null);
42
+ });
43
+
44
+ after(() => fs.rmSync(root, { recursive: true, force: true }));
20
45
 
21
46
  /** A throwaway project directory holding one .ddev/config.yaml. */
22
47
  function project(yaml) {
@@ -130,11 +155,10 @@ additional_fqdns:
130
155
  'only a host whose FIRST label is storybook is the component library');
131
156
  });
132
157
 
133
- /* ---------------- against the real tree ---------------- */
158
+ /* ---------------- against the tree built above ---------------- */
134
159
 
135
160
  test('the projects in this tree that ship a Storybook are found', () => {
136
161
  // The regression in one line: before this parsed additional_fqdns, every one of these was absent.
137
- const { loadWorkspaces } = require('../../workspaces');
138
162
  const found = [];
139
163
  for (const w of Object.values(loadWorkspaces())) {
140
164
  if (!w.dir) continue;
@@ -148,6 +172,8 @@ test('the projects in this tree that ship a Storybook are found', () => {
148
172
  }
149
173
  }
150
174
  assert.ok(found.length > 0,
151
- 'no project in the tree declares a Storybook domain — either the tree changed or the fqdn key stopped being read');
175
+ 'no project in the tree declares a Storybook domain: the fqdn key stopped being read');
152
176
  for (const p of found) assert.ok(p.includes('/'), p);
177
+ assert.deepEqual(found, ['dev/blog'],
178
+ 'the one with a storybook fqdn, and not the label-only, the mail-only or the project with no config');
153
179
  });
@@ -61,11 +61,14 @@ function tree() {
61
61
  fs.writeFileSync(path.join(dir, 'index.js'), `
62
62
  module.exports.register = (api) => {
63
63
  api.addRowMenu('x-cloud', { label: 'X CLOUD', row: 0 }, ({ key, project, allowed }) => allowed.has('x-cloud') ? '<div class="act-menu-x-cloud">' + key + '/' + project + '</div>' : null);
64
+ // The lazy form: the button and the URL of the body, which the fragment below answers.
65
+ api.addRowMenu('x-lazy', { label: 'X LAZY', row: 0 }, ({ key, project, allowed }) => allowed.has('x-cloud')
66
+ ? { html: '<button class="x-lazy-button">X LAZY</button>', body: '/fragments/p/x-cloud/menu/' + key + '/' + project, caption: 'not linked', classes: 'act-menu-hosting x-family' } : null);
64
67
  api.addPage(/^\\/x-cloud\\/([a-z]+)$/, ({ send, match }) => send('page:' + match[1]));
65
68
  api.addProjectChip(({ project }) => '<span class="x-chip">' + project + '</span>');
66
69
  api.addFolderSetup({ html: () => '<div class="x-setup"></div>', steps: (ws, name, form) => form.x === '1' ? ['echo x ' + name] : [], prompt: () => ['x prompt'], problem: (ws, form) => form.x === 'bad' ? 'bad x' : '' });
67
70
  api.addAction('ping', ({ send }) => send('pong'));
68
- api.addFragment(({ send, rest }) => send('frag:' + rest));
71
+ api.addFragment(({ send, rest }) => send(rest.startsWith('menu/') ? '<ul class="uk-nav uk-dropdown-nav"><li class="uk-nav-header">Menu for ' + rest.slice(5) + '</li><li><a href="#" class="x-lazy-item">Deploy</a></li></ul>' : 'frag:' + rest));
69
72
  api.addLabels({ region: { label: 'Region' } });
70
73
  api.addForge('forge.test', { label: 'X Forge', env: () => ({ X_TOKEN: 'secret' }), auth: () => ({ username: 'me', how: 'token' }) });
71
74
  api.addWorkspaceView('x-store', { listHtml: (state) => '<div class="x-list">' + (state.q || '') + '</div>', count: () => 7, noNewForm: true });
@@ -128,6 +131,40 @@ test('a plugin reaches every hook, and what it registers is what the dashboard d
128
131
  assert.strictEqual(r.noView, null);
129
132
  });
130
133
 
134
+ test('a row menu that answers { html, body } is drawn as its button and an empty shell; the body is fetched, through the core, when it opens', async () => {
135
+ const t = tree();
136
+ const out = node(`
137
+ const l = require('./plugin-loader');
138
+ const vals = () => '';
139
+ const menus = l.pluginRowMenus({ key: 'dev', project: 'site1', dir: '${t.root}/dev', meta: {}, allowed: new Set(['x-cloud']), vals });
140
+ const none = l.pluginRowMenus({ key: 'modules', project: 'm', dir: '${t.root}/modules', meta: {}, allowed: new Set(), vals });
141
+ const bad = l.rowMenuHtml('x-lazy', { html: '<button>x</button>', body: 'https://elsewhere.test/menu' }, { key: 'dev', project: 'site1' });
142
+ const noCaption = l.rowMenuHtml('x-lazy', { html: '<button>x</button>', body: '/fragments/p/x-cloud/menu/dev/site1' }, { key: 'dev', project: 'site1' });
143
+ Promise.all([
144
+ l.pluginRowMenuBody('/fragments/p/x-cloud/menu/dev/site1', { headers: {} }),
145
+ l.pluginRowMenuBody('/fragments/p/nobody/menu/dev/site1', { headers: {} }),
146
+ ]).then(([body, missing]) => {
147
+ console.log(JSON.stringify({ lazy: menus['x-lazy'], inline: menus['x-cloud'], none: none['x-lazy'], bad, noCaption, body, missing,
148
+ slot: l.pluginRowMenuSlot('x-lazy'), noSlot: l.pluginRowMenuSlot('x-other'), capability: l.loadedPlugins.length > 0 }));
149
+ });`, env(t));
150
+ const r = JSON.parse(out);
151
+ const lazy = r.lazy;
152
+ assert.ok(lazy.startsWith('<span class="act-with-status"><div class="uk-inline act-menu act-menu-x-lazy act-menu-hosting x-family">'), 'the wrapper the row layout expects, with the slot and the plugin\'s classes');
153
+ assert.ok(lazy.includes('<button class="x-lazy-button">X LAZY</button>'), 'the button as the plugin drew it');
154
+ assert.ok(lazy.includes('hx-get="/fragments/row-menu/x-lazy/dev/site1?body=%2Ffragments%2Fp%2Fx-cloud%2Fmenu%2Fdev%2Fsite1"'), 'the shell asks the core for the body, naming the plugin\'s URL');
155
+ assert.ok(lazy.includes('hx-trigger="menu-opened" hx-target="this" hx-swap="innerHTML"'), 'fetched when the menu opens, into the dropdown');
156
+ assert.ok(lazy.includes('<div uk-dropdown="mode: click; pos: bottom-right" class="act-menu-lazy"'), 'an ordinary UIkit dropdown shell');
157
+ assert.ok(lazy.endsWith('<span class="act-status uk-text-meta">not linked</span></span>'), 'the caption beside the button');
158
+ assert.ok(!lazy.includes('Menu for') && !lazy.includes('x-lazy-item'), 'the page carries no menu body');
159
+ assert.strictEqual(r.inline, '<div class="act-menu-x-cloud">dev/site1</div>', 'a string return is drawn inline, as before');
160
+ assert.strictEqual(r.none, '', 'null is an empty slot, in either form');
161
+ assert.strictEqual(r.bad, '', 'a body outside /fragments/p/<plugin>/ is not a menu');
162
+ assert.ok(!r.noCaption.includes('act-status'), 'no caption, no caption span');
163
+ assert.deepStrictEqual(r.body, { status: 200, html: '<ul class="uk-nav uk-dropdown-nav"><li class="uk-nav-header">Menu for dev/site1</li><li><a href="#" class="x-lazy-item">Deploy</a></li></ul>' }, 'the body is the plugin\'s fragment, answered as a value');
164
+ assert.strictEqual(r.missing.status, 404, 'a URL no plugin serves is 404');
165
+ assert.ok(r.slot && !r.noSlot, 'the core knows which slots are plugin menus');
166
+ });
167
+
131
168
  test('a dotted legacy key names a nested settings.yml block, for the reader and the installer', () => {
132
169
  const t = tree();
133
170
  const dir = path.join(t.plugins, 'x-forge');
@@ -0,0 +1,21 @@
1
+ 'use strict';
2
+ // The precondition helpers read the machine's settings files the way the hooks do.
3
+ const test = require('node:test');
4
+ const assert = require('node:assert');
5
+ const fs = require('fs');
6
+ const path = require('path');
7
+ const os = require('os');
8
+
9
+ test('a plugin setting is read from the plugin\'s own file, and is empty when the file or the key is absent', () => {
10
+ const tmp = fs.mkdtempSync(path.join(os.tmpdir(), 'ws-precond-'));
11
+ fs.mkdirSync(path.join(tmp, 'core', 'config'), { recursive: true });
12
+ fs.writeFileSync(path.join(tmp, 'core', 'config', 'plugin.jira.settings.yml'), 'site: "https://example.atlassian.net"\ndefault_project: WEBSHIP # the key folders inherit\nallow_writes: false\n');
13
+ // The hooks resolve ROOT from their own location; the helper is exercised through the same regex on a copy.
14
+ const src = fs.readFileSync(path.join(__dirname, '..', 'step-definitions', 'preconditions.steps.js'), 'utf8');
15
+ const fn = new Function('fs', 'path', 'ROOT', src.match(/function pluginSettingValue[\s\S]*?\n}\n/)[0] + '; return pluginSettingValue;')(fs, path, tmp);
16
+ assert.strictEqual(fn('jira', 'default_project'), 'WEBSHIP');
17
+ assert.strictEqual(fn('jira', 'site'), 'https://example.atlassian.net');
18
+ assert.strictEqual(fn('jira', 'missing_key'), '');
19
+ assert.strictEqual(fn('nope', 'default_project'), '');
20
+ fs.rmSync(tmp, { recursive: true, force: true });
21
+ });
@@ -514,7 +514,10 @@ test('the row, the SCREENS fragment and the Commands page are wired to rows mana
514
514
  assert.match(src, /const extraRows = rowLines\.extra\.filter\(Boolean\);/);
515
515
  assert.match(src, /\$\{extraRows\.join\(''\)\}/);
516
516
  assert.match(src, /const ddevItems = rowLayout\.arrangeNavInner\('ddev', ddevMenuItems, rowCtx\);/);
517
- assert.match(src, /return res\.end\(rowLayout\.arrangeNavInner\('screens', screensNav,/);
517
+ // The SCREENS body is arranged where it is served: the legacy fragment, and the row-menu route every
518
+ // core menu (DDEV, COMPOSER, Tests, SCREENS) loads its body through when it opens — arranged, then columns.
519
+ assert.match(src, /return res\.end\(rowLayout\.arrangeNavInner\('screens', nav,/);
520
+ assert.match(src, /rowLayout\.columnizeButtonHtml\(slot, rowLayout\.arrangeButtonHtml\(slot, html, ctx\), layout\)/);
518
521
  assert.match(src, /'\/settings\/buttons'[\s\S]{0,400}settingsPage\('buttons'/);
519
522
  assert.match(src, /\.\.\.require\('\.\/actions\/row-actions'\)/);
520
523
  const ui = fs.readFileSync(path.join(__dirname, '..', '..', 'public', 'ui.js'), 'utf8');