@webship.co/workspace 1.0.0-beta2 → 1.0.0-rc1
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 +9 -1
- package/core/workspace-app/package.json +2 -2
- package/core/workspace-app/tests/features/assistant-speech.feature +2 -2
- package/core/workspace-app/tests/features/checks.feature +1 -0
- package/core/workspace-app/tests/features/clone-project.feature +4 -4
- package/core/workspace-app/tests/features/project-actions.feature +4 -3
- 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/ddev-domains.test.js +31 -5
- package/core/workspace-app/tests/unit/preconditions.test.js +21 -0
- 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
|
@@ -39,7 +39,8 @@ the same shape, which is how one is developed before it gets a repository.
|
|
|
39
39
|
"machine": ["scripts/upsun-ai.sh"], "hostInstall": "scripts/host-install.sh" },
|
|
40
40
|
"ddev": { "compose": ["ddev/docker-compose.upsun-cli.yaml"], "hostMounts": ["~/.upsun-cli"] },
|
|
41
41
|
"themes": ["config/theme.upsun.yml"],
|
|
42
|
-
"css": ["public/upsun.css"]
|
|
42
|
+
"css": ["public/upsun.css"],
|
|
43
|
+
"features": ["features/upsun.feature"] // webship-js scenarios, run by the dashboard's own suite
|
|
43
44
|
}
|
|
44
45
|
```
|
|
45
46
|
|
|
@@ -51,6 +52,13 @@ compose files as `core/workspace-app/.ddev/docker-compose.plugin-<name>-<file>`;
|
|
|
51
52
|
`host-mounts.txt` that `host-mounts.sh` reads; themes in `core/config/`. `hostInstall` runs with
|
|
52
53
|
`--check` after an install and in full from `install.sh`.
|
|
53
54
|
|
|
55
|
+
Features land in `core/workspace-app/tests/features/plugins/<name>/`, so `ddev test-dashboard browser`
|
|
56
|
+
runs them with the core's. A scenario that only holds when one of the plugin's settings is set says
|
|
57
|
+
so in a tag: `@needs-setting-<plugin>-<key>` skips it, with the reason, while
|
|
58
|
+
`core/config/plugin.<plugin>.settings.yml` has no value for `<key>` (the Jira feature tags its menu
|
|
59
|
+
scenario `@needs-setting-jira-default_project`). A plugin ships no step definitions: webship-js's
|
|
60
|
+
own steps and the core's are what a feature can use.
|
|
61
|
+
|
|
54
62
|
Everything stays HTMX + UIKit: a plugin serves server-rendered fragments and wires them with
|
|
55
63
|
`hx-get`/`hx-post`; no client frameworks.
|
|
56
64
|
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "workspace-dashboard",
|
|
3
|
-
"version": "1.0.0-
|
|
4
|
-
"description": "The Webship Workspace dashboard: a lean core (workspaces, builders, settings, the assistant shell, the plugin system) that every feature joins as a @webship.co/workspace-<name> plugin
|
|
3
|
+
"version": "1.0.0-rc1",
|
|
4
|
+
"description": "The Webship Workspace dashboard: a lean core (workspaces, builders, settings, the assistant shell, the plugin system) that every feature joins as a @webship.co/workspace-<name> plugin — required with npm, then activated, installed and configured.",
|
|
5
5
|
"license": "ISC",
|
|
6
6
|
"main": "server.js",
|
|
7
7
|
"scripts": {
|
|
@@ -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
|
|
@@ -31,6 +31,7 @@ Feature: The checks a project already declares
|
|
|
31
31
|
|
|
32
32
|
# A site gets the same nested list: its package.json declares the same vocabulary, and its
|
|
33
33
|
# ddev-dev-tools add-on adds the PHP and Twig checks on top.
|
|
34
|
+
@needs-testing-stack
|
|
34
35
|
Scenario: A site keeps its testing verbs and gains the checks beside them
|
|
35
36
|
Given I am on "/dev"
|
|
36
37
|
Then the element ".project-row .act-menu-tests" should be displayed
|
|
@@ -7,14 +7,14 @@ Feature: Cloning a project
|
|
|
7
7
|
Then "[hx-get^='/fragments/projects/clone-form/dev/']" should be attached
|
|
8
8
|
|
|
9
9
|
Scenario: The form arrives prefilled, with the live check wired
|
|
10
|
-
|
|
11
|
-
Then
|
|
10
|
+
When I open the clone form of the first project in "dev"
|
|
11
|
+
Then the clone form proposes a copy of that project
|
|
12
12
|
And "select[name='toWorkspace']" should be attached
|
|
13
13
|
And "[hx-get='/fragments/projects/clone-check']" should be attached
|
|
14
14
|
And "textarea[name='prompt']" should be attached
|
|
15
15
|
|
|
16
16
|
Scenario: The live check refuses an existing project
|
|
17
|
-
|
|
17
|
+
When I ask the live check about the name of the first project in "dev"
|
|
18
18
|
Then I should see "already exists"
|
|
19
19
|
|
|
20
20
|
Scenario: The live check blesses a free name
|
|
@@ -22,7 +22,7 @@ Feature: Cloning a project
|
|
|
22
22
|
Then I should see "is free"
|
|
23
23
|
|
|
24
24
|
Scenario: Cloning over an existing project is refused server-side too
|
|
25
|
-
When I post
|
|
25
|
+
When I post a clone of the first project in "dev" over itself
|
|
26
26
|
Then the action response should contain "already exists"
|
|
27
27
|
|
|
28
28
|
Scenario: A source that does not exist is refused
|
|
@@ -15,6 +15,7 @@ Feature: What a project row can actually do
|
|
|
15
15
|
|
|
16
16
|
# These two are the whole testing feature as far as the interface is concerned: one sets the
|
|
17
17
|
# environment up, the other runs the suite. Both post to their own action.
|
|
18
|
+
@needs-testing-stack
|
|
18
19
|
Scenario: The Tests menu offers setting the environment up and running the suite
|
|
19
20
|
Given I am on "/dev"
|
|
20
21
|
Then "[hx-post='/actions/testing-configure']" should be attached
|
|
@@ -34,7 +35,7 @@ Feature: What a project row can actually do
|
|
|
34
35
|
And I should see "Describe (URLs, services)"
|
|
35
36
|
|
|
36
37
|
# Launch belongs with the hostname it opens, not in the row of things you DO to a project.
|
|
37
|
-
|
|
38
|
+
@needs-running-project
|
|
39
|
+
Scenario: A running project shows its own host on its card
|
|
38
40
|
Given I am on "/dev"
|
|
39
|
-
Then
|
|
40
|
-
And the element ".proj-launch-line .proj-alias" should be displayed
|
|
41
|
+
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
|
});
|
|
@@ -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
|
});
|
|
@@ -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
|
+
});
|
|
@@ -5,12 +5,36 @@
|
|
|
5
5
|
* of `screen`, so they are pinned here rather than left to the browser tests.
|
|
6
6
|
*/
|
|
7
7
|
|
|
8
|
-
const { test } = require('node:test');
|
|
8
|
+
const { test, before, after } = require('node:test');
|
|
9
9
|
const assert = require('node:assert');
|
|
10
|
+
const fs = require('fs');
|
|
11
|
+
const os = require('os');
|
|
10
12
|
const path = require('path');
|
|
13
|
+
const { copyConfig } = require('./_isolated-config');
|
|
14
|
+
|
|
15
|
+
// The targets are read from a tree built here, not from this checkout: a fresh clone holds the
|
|
16
|
+
// workspace folders and no project in any of them. The modules read their root once, at load, so
|
|
17
|
+
// it is set before they are required.
|
|
18
|
+
const APP = path.join(__dirname, '../..');
|
|
19
|
+
const REPO = path.join(APP, '../..');
|
|
20
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ws-screens-root-'));
|
|
21
|
+
process.env.WORKSPACE_ROOT = root;
|
|
11
22
|
|
|
12
23
|
const { screenTargets, screenTargetDir } = require('../../screens');
|
|
13
|
-
const { ROOT } = require('../../workspaces');
|
|
24
|
+
const { ROOT, loadWorkspaces } = require('../../workspaces');
|
|
25
|
+
|
|
26
|
+
before(() => {
|
|
27
|
+
fs.mkdirSync(path.join(root, 'core'), { recursive: true });
|
|
28
|
+
copyConfig(path.join(REPO, 'core/config'), path.join(root, 'core/config'));
|
|
29
|
+
fs.symlinkSync(path.join(REPO, 'core/scripts'), path.join(root, 'core/scripts'));
|
|
30
|
+
fs.symlinkSync(APP, path.join(root, 'core/workspace-app'));
|
|
31
|
+
// Every workspace folder, as a clone has them; one project in dev/; a dot folder, which is not one.
|
|
32
|
+
for (const w of Object.values(loadWorkspaces())) fs.mkdirSync(w.dir, { recursive: true });
|
|
33
|
+
fs.mkdirSync(path.join(root, 'dev', 'blog1', '.ddev'), { recursive: true });
|
|
34
|
+
fs.mkdirSync(path.join(root, 'dev', '.cache'), { recursive: true });
|
|
35
|
+
});
|
|
36
|
+
|
|
37
|
+
after(() => fs.rmSync(root, { recursive: true, force: true }));
|
|
14
38
|
|
|
15
39
|
test('the workspace root is always a place to start a session', () => {
|
|
16
40
|
const root = screenTargets().find((t) => t.value === 'root');
|
|
@@ -38,6 +62,10 @@ test('workspace folders and projects are offered separately', () => {
|
|
|
38
62
|
assert.ok(targets.some((t) => t.group === 'Projects'), 'at least one project is offered');
|
|
39
63
|
const dev = targets.find((t) => t.value === 'ws:dev');
|
|
40
64
|
assert.ok(dev && dev.dir === path.join(ROOT, 'dev'));
|
|
65
|
+
const blog = targets.find((t) => t.value === 'p:dev:blog1');
|
|
66
|
+
assert.ok(blog && blog.group === 'Projects' && blog.dir === path.join(ROOT, 'dev', 'blog1'),
|
|
67
|
+
'the project in dev/ is offered under Projects');
|
|
68
|
+
assert.ok(!targets.some((t) => t.value === 'p:dev:.cache'), 'a dot folder is not a project');
|
|
41
69
|
});
|
|
42
70
|
|
|
43
71
|
// The name rule is shared by the form, the actions and both /fragments routes:
|
|
@@ -55,20 +83,11 @@ test('a session name may not start with a dash, and holds no shell characters',
|
|
|
55
83
|
// share: the runner republishes its list every two seconds, so a stale file
|
|
56
84
|
// means the runner is gone and nothing new may be started.
|
|
57
85
|
test('a published session list is trusted only while it is fresh', () => {
|
|
58
|
-
const fs = require('fs');
|
|
59
|
-
const path = require('path');
|
|
60
|
-
const os = require('os');
|
|
61
|
-
const { ROOT } = require('../../workspaces');
|
|
62
86
|
const file = path.join(ROOT, '.screens', 'host-sessions.json');
|
|
63
|
-
const had = fs.existsSync(file) ? fs.readFileSync(file) : null;
|
|
64
87
|
const { hostSessionsFile } = require('../../jobs');
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
assert.equal(hostSessionsFile(), null, 'a minute-old list means the runner stopped');
|
|
71
|
-
} finally {
|
|
72
|
-
if (had) fs.writeFileSync(file, had); else fs.rmSync(file, { force: true });
|
|
73
|
-
}
|
|
88
|
+
fs.mkdirSync(path.dirname(file), { recursive: true });
|
|
89
|
+
fs.writeFileSync(file, JSON.stringify({ at: Date.now(), host: os.hostname(), sessions: [{ session: 'a--b--c' }] }));
|
|
90
|
+
assert.equal(hostSessionsFile().sessions.length, 1, 'a list written now is the truth');
|
|
91
|
+
fs.writeFileSync(file, JSON.stringify({ at: Date.now() - 60000, sessions: [{ session: 'a--b--c' }] }));
|
|
92
|
+
assert.equal(hostSessionsFile(), null, 'a minute-old list means the runner stopped');
|
|
74
93
|
});
|
|
@@ -70,7 +70,12 @@ before(async () => {
|
|
|
70
70
|
fs.copyFileSync(path.join(REPO, 'videos/cmd-tools-video-edit.sh'), path.join(root, 'videos/cmd-tools-video-edit.sh'));
|
|
71
71
|
fs.writeFileSync(path.join(root, 'docs/main-voiceover-script.md'), MAIN_SCRIPT);
|
|
72
72
|
fs.writeFileSync(path.join(root, 'docs/other-voiceover-script.md'), OTHER_SCRIPT);
|
|
73
|
-
if (!CAN_RUN)
|
|
73
|
+
if (!CAN_RUN) {
|
|
74
|
+
// No ffmpeg on this machine: the engine tests are skipped, but the form and the fragments read only a
|
|
75
|
+
// video's name beside its script, so a file by that name is enough for them to run here too.
|
|
76
|
+
for (const n of ['main.mp4', 'other.mp4']) fs.writeFileSync(V(n), Buffer.alloc(100, 1));
|
|
77
|
+
return;
|
|
78
|
+
}
|
|
74
79
|
const make = (name, source, dur, tone) => run('ffmpeg', ['-y', '-loglevel', 'error', '-f', 'lavfi', '-i', `${source}:rate=15:duration=${dur}`,
|
|
75
80
|
'-f', 'lavfi', '-i', `sine=frequency=${tone}:duration=${dur}`, '-c:v', 'libx264', '-preset', 'ultrafast', '-pix_fmt', 'yuv420p', '-c:a', 'aac',
|
|
76
81
|
'-shortest', V(name)], { env: env() });
|
|
@@ -78,10 +83,27 @@ before(async () => {
|
|
|
78
83
|
await make('other.mp4', 'smptebars=size=200x200', 9, 880);
|
|
79
84
|
});
|
|
80
85
|
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
86
|
+
/**
|
|
87
|
+
* Stop the servers this file started, then remove a tree, trying again for a moment. The server runs `ddev list` with
|
|
88
|
+
* HOME inside the tree, and a ddev still finishing after the server is gone puts `~/.ddev` back while the tree is
|
|
89
|
+
* being removed: a single rmSync then fails with ENOTEMPTY and the tree is left in /tmp.
|
|
90
|
+
*/
|
|
91
|
+
async function stopAndRemove(dirs) {
|
|
92
|
+
await Promise.all(servers.map((s) => new Promise((resolve) => {
|
|
93
|
+
if (s.exitCode !== null || s.signalCode !== null) return resolve();
|
|
94
|
+
s.once('exit', resolve);
|
|
95
|
+
s.kill();
|
|
96
|
+
})));
|
|
97
|
+
for (const dir of dirs) {
|
|
98
|
+
for (let i = 0; i < 50; i += 1) {
|
|
99
|
+
try { fs.rmSync(dir, { recursive: true, force: true }); } catch (_) { /* something was still writing */ }
|
|
100
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
101
|
+
if (!fs.existsSync(dir)) break;
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
after(() => stopAndRemove([root]));
|
|
85
107
|
|
|
86
108
|
const args = (...more) => ['-a', 'edit', '--video', V('main.mp4'), '--script', path.join(root, 'docs/main-voiceover-script.md'), ...more];
|
|
87
109
|
const borrow = (what, after = 'end', script = true) => ['--borrow', [V('other.mp4'), what, after, script ? path.join(root, 'docs/other-voiceover-script.md') : ''].join('|').replace(/\|+$/, '')];
|
|
@@ -79,17 +79,36 @@ before(async () => {
|
|
|
79
79
|
await run('ffmpeg', ['-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'testsrc2=size=320x180:rate=15', '-f', 'lavfi', '-i', 'sine=frequency=440',
|
|
80
80
|
'-t', '6', '-c:v', 'libx264', '-preset', 'ultrafast', '-pix_fmt', 'yuv420p', '-c:a', 'aac', path.join(root, 'videos/demo.mp4')], { env });
|
|
81
81
|
await run('ffmpeg', ['-y', '-loglevel', 'error', '-f', 'lavfi', '-i', 'sine=frequency=220:duration=10', '-c:a', 'libmp3lame', path.join(root, 'audio/sounds/bed.mp3')], { env });
|
|
82
|
-
fs.writeFileSync(path.join(root, 'videos/demo-poster.jpg'), Buffer.alloc(50, 2));
|
|
83
82
|
} else {
|
|
83
|
+
// No ffmpeg on this machine: the command tests are skipped, and the page tests read only the files' names.
|
|
84
84
|
fs.writeFileSync(path.join(root, 'videos/demo.mp4'), Buffer.alloc(100, 1));
|
|
85
85
|
fs.writeFileSync(path.join(root, 'audio/sounds/bed.mp3'), Buffer.alloc(100, 1));
|
|
86
86
|
}
|
|
87
|
+
// The poster is the row's thumbnail whichever way the video was made.
|
|
88
|
+
fs.writeFileSync(path.join(root, 'videos/demo-poster.jpg'), Buffer.alloc(50, 2));
|
|
87
89
|
});
|
|
88
90
|
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
91
|
+
/**
|
|
92
|
+
* Stop the servers this file started, then remove a tree, trying again for a moment. The server runs `ddev list` with
|
|
93
|
+
* HOME inside the tree, and a ddev still finishing after the server is gone puts `~/.ddev` back while the tree is
|
|
94
|
+
* being removed: a single rmSync then fails with ENOTEMPTY and the tree is left in /tmp.
|
|
95
|
+
*/
|
|
96
|
+
async function stopAndRemove(dirs) {
|
|
97
|
+
await Promise.all(servers.map((s) => new Promise((resolve) => {
|
|
98
|
+
if (s.exitCode !== null || s.signalCode !== null) return resolve();
|
|
99
|
+
s.once('exit', resolve);
|
|
100
|
+
s.kill();
|
|
101
|
+
})));
|
|
102
|
+
for (const dir of dirs) {
|
|
103
|
+
for (let i = 0; i < 50; i += 1) {
|
|
104
|
+
try { fs.rmSync(dir, { recursive: true, force: true }); } catch (_) { /* something was still writing */ }
|
|
105
|
+
await new Promise((r) => setTimeout(r, 100));
|
|
106
|
+
if (!fs.existsSync(dir)) break;
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
after(() => stopAndRemove([root, home]));
|
|
93
112
|
|
|
94
113
|
const env = (extra = {}) => ({ PATH: `/usr/bin:/bin:${path.dirname(process.execPath)}:${path.join(home, 'bin')}`, HOME: home, WORKSPACE_ROOT: root, ...extra });
|
|
95
114
|
const cli = (args) => run('bash', [path.join(root, 'videos/cmd-tools-video-edit.sh'), ...args], { env: env() });
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@webship.co/workspace",
|
|
3
|
-
"version": "1.0.0-
|
|
3
|
+
"version": "1.0.0-rc1",
|
|
4
4
|
"description": "Webship Workspace: a container-based development workspace with a web dashboard. The core ships empty; workspace folders, hosting providers, git forges, container runtimes and features are @webship.co/workspace-<name> plugins you add one at a time.",
|
|
5
5
|
"license": "GPL-2.0-or-later",
|
|
6
6
|
"repository": {
|