@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.
- package/core/workspace-app/PLUGINS.md +55 -2
- package/core/workspace-app/package.json +2 -2
- package/core/workspace-app/plugin-loader.js +85 -4
- package/core/workspace-app/plugins-page.js +1 -1
- package/core/workspace-app/public/ui.js +19 -3
- package/core/workspace-app/server.js +461 -276
- package/core/workspace-app/tests/features/assistant-speech.feature +2 -2
- package/core/workspace-app/tests/features/checks.feature +23 -6
- package/core/workspace-app/tests/features/clone-project.feature +5 -4
- package/core/workspace-app/tests/features/project-actions.feature +15 -8
- package/core/workspace-app/tests/step-definitions/assistant-speech.steps.js +10 -5
- package/core/workspace-app/tests/step-definitions/clone-project.steps.js +47 -0
- package/core/workspace-app/tests/step-definitions/preconditions.steps.js +69 -1
- package/core/workspace-app/tests/unit/changelog.test.js +49 -3
- package/core/workspace-app/tests/unit/claude-code-here.test.js +1 -1
- package/core/workspace-app/tests/unit/ddev-domains.test.js +31 -5
- package/core/workspace-app/tests/unit/plugin-system.test.js +38 -1
- package/core/workspace-app/tests/unit/preconditions.test.js +21 -0
- package/core/workspace-app/tests/unit/row-layout.test.js +4 -1
- package/core/workspace-app/tests/unit/row-menu-fragments.test.js +229 -0
- package/core/workspace-app/tests/unit/screens-menu.test.js +7 -2
- package/core/workspace-app/tests/unit/screens.test.js +35 -16
- package/core/workspace-app/tests/unit/video-borrow.test.js +27 -5
- package/core/workspace-app/tests/unit/video-edit.test.js +24 -5
- 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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
11
|
-
Then
|
|
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
|
-
|
|
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
|
|
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
|
|
6
|
-
#
|
|
7
|
-
#
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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.
|
|
45
|
-
assert.
|
|
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
|
-
|
|
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
|
|
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
|
|
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\(
|
|
9
|
+
assert.match(server, /screen-new-form\?workspace=\$\{encodeURIComponent\(ws\)\}&project=\$\{encodeURIComponent\(project\)\}&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 {
|
|
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
|
|
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
|
|
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
|
-
|
|
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');
|