@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
|
@@ -0,0 +1,229 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The core's row menus load their bodies when they open (#109).
|
|
3
|
+
*
|
|
4
|
+
* A page of fifty project rows carried fifty copies of the DDEV, COMPOSER, Tests and SCREENS menus —
|
|
5
|
+
* about 1.2 MB of dropdowns nobody had opened. Now each row carries the button, its caption and an
|
|
6
|
+
* empty shell (plugin-loader's menuShellHtml, the one the plugins' lazy menus use), and the body comes
|
|
7
|
+
* from /fragments/row-menu/<slot>/<ws>/<project> when the menu opens, computed for that one project.
|
|
8
|
+
*
|
|
9
|
+
* Against a real server over a temp workspace tree: a sites workspace with a DDEV project that has a
|
|
10
|
+
* composer.json and the testing scripts, and a packages workspace with a package that declares checks.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
const { test, before, after } = require('node:test');
|
|
14
|
+
const assert = require('node:assert');
|
|
15
|
+
const fs = require('fs');
|
|
16
|
+
const os = require('os');
|
|
17
|
+
const path = require('path');
|
|
18
|
+
const net = require('net');
|
|
19
|
+
const { spawn } = require('child_process');
|
|
20
|
+
|
|
21
|
+
const APP = path.join(__dirname, '../..');
|
|
22
|
+
const REPO = path.join(APP, '../..');
|
|
23
|
+
const { copyConfig } = require('./_isolated-config');
|
|
24
|
+
|
|
25
|
+
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'ws-row-menus-'));
|
|
26
|
+
let proc;
|
|
27
|
+
let base;
|
|
28
|
+
|
|
29
|
+
const free = () => new Promise((resolve) => {
|
|
30
|
+
const s = net.createServer();
|
|
31
|
+
s.listen(0, '127.0.0.1', () => { const { port } = s.address(); s.close(() => resolve(port)); });
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
before(async () => {
|
|
35
|
+
fs.mkdirSync(path.join(root, 'core'), { recursive: true });
|
|
36
|
+
copyConfig(path.join(REPO, 'core/config'), path.join(root, 'core/config'));
|
|
37
|
+
fs.symlinkSync(path.join(REPO, 'core/scripts'), path.join(root, 'core/scripts'));
|
|
38
|
+
fs.symlinkSync(APP, path.join(root, 'core/workspace-app'));
|
|
39
|
+
// The layout the rows and the fragments arrange by, blank: a machine's own row-actions.yml must not
|
|
40
|
+
// decide what this test sees.
|
|
41
|
+
for (const f of ['row-actions.yml', 'row-actions.seen.json']) fs.rmSync(path.join(root, 'core/config', f), { force: true });
|
|
42
|
+
// A site: DDEV-configured, with a composer.json and the scripts that gate Tests, test users and checks.
|
|
43
|
+
const site = path.join(root, 'dev/site-one');
|
|
44
|
+
fs.mkdirSync(path.join(site, '.ddev'), { recursive: true });
|
|
45
|
+
fs.writeFileSync(path.join(site, '.ddev/config.yaml'), 'name: site-one\ntype: drupal\ndocroot: web\n');
|
|
46
|
+
fs.writeFileSync(path.join(site, 'composer.json'), JSON.stringify({ name: 'acme/site-one', require: { 'drupal/core': '^11' }, scripts: { 'post-install-cmd': ['echo hi'], 'site:check': 'echo check' } }));
|
|
47
|
+
fs.writeFileSync(path.join(site, 'composer.lock'), '{}');
|
|
48
|
+
fs.writeFileSync(path.join(site, 'package.json'), JSON.stringify({ name: 'site-one', scripts: { 'lint:js': 'eslint .' } }));
|
|
49
|
+
fs.mkdirSync(path.join(root, 'dev/plain-folder'), { recursive: true });
|
|
50
|
+
for (const f of ['cmd-tools-testing.sh', 'cmd-tools-checks.sh', 'cmd-tools-add-users.sh']) fs.writeFileSync(path.join(root, 'dev', f), '#!/usr/bin/env bash\n');
|
|
51
|
+
// A package: no DDEV, a package.json that declares checks.
|
|
52
|
+
const pkg = path.join(root, 'products/pkg-one');
|
|
53
|
+
fs.mkdirSync(pkg, { recursive: true });
|
|
54
|
+
fs.writeFileSync(path.join(pkg, 'package.json'), JSON.stringify({ name: 'pkg-one', scripts: { 'lint:js': 'eslint .', 'lint:css': 'stylelint "**/*.css"' } }));
|
|
55
|
+
fs.writeFileSync(path.join(pkg, 'composer.json'), JSON.stringify({ name: 'acme/pkg-one', type: 'drupal-module' }));
|
|
56
|
+
fs.writeFileSync(path.join(root, 'products', 'cmd-tools-checks.sh'), '#!/usr/bin/env bash\n');
|
|
57
|
+
const port = await free();
|
|
58
|
+
base = `http://127.0.0.1:${port}`;
|
|
59
|
+
proc = spawn(process.execPath, ['server.js'], { cwd: APP, env: { ...process.env, PORT: String(port), WORKSPACE_ROOT: root, WORKSPACE_PLUGINS_NO_RESTART: '1' }, stdio: ['ignore', 'pipe', 'pipe'] });
|
|
60
|
+
await new Promise((resolve, reject) => {
|
|
61
|
+
const t = setTimeout(() => reject(new Error('server did not start')), 20000);
|
|
62
|
+
proc.stdout.on('data', (d) => { if (/listening/.test(String(d))) { clearTimeout(t); resolve(); } });
|
|
63
|
+
proc.on('exit', (c) => { clearTimeout(t); reject(new Error(`server exited ${c}`)); });
|
|
64
|
+
});
|
|
65
|
+
});
|
|
66
|
+
|
|
67
|
+
after(() => {
|
|
68
|
+
if (proc) proc.kill();
|
|
69
|
+
fs.rmSync(root, { recursive: true, force: true });
|
|
70
|
+
});
|
|
71
|
+
|
|
72
|
+
const get = (p) => fetch(base + p);
|
|
73
|
+
const text = async (p) => { const r = await get(p); return { status: r.status, html: await r.text() }; };
|
|
74
|
+
/** One row's HTML out of a workspace page. */
|
|
75
|
+
const rowOf = (html, name, anchor = `"projectName":"${name}"`) => {
|
|
76
|
+
const at = html.indexOf(anchor);
|
|
77
|
+
assert.ok(at > 0, `no row for ${name}`);
|
|
78
|
+
// A row is a card: `<div class="uk-card … project-row pcard">` opens it, the next card ends it.
|
|
79
|
+
const start = html.lastIndexOf('project-row pcard', at);
|
|
80
|
+
const end = html.indexOf('project-row pcard', at);
|
|
81
|
+
return html.slice(start, end < 0 ? undefined : end);
|
|
82
|
+
};
|
|
83
|
+
/** The menu wrapper of one slot inside a row: the shell and what follows it up to the caption. */
|
|
84
|
+
const menuOf = (row, slot) => {
|
|
85
|
+
const m = new RegExp(`<span class="act-with-status"><div class="uk-inline act-menu act-menu-${slot}[^"]*">[\\s\\S]*?</div></div>(<span class="act-status uk-text-meta">[^<]*</span>)?</span>`).exec(row);
|
|
86
|
+
return m ? m[0] : '';
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
test('a project row carries the button, the caption and an empty shell for each core menu, and no menu body', async () => {
|
|
90
|
+
const { status, html } = await text('/dev');
|
|
91
|
+
assert.strictEqual(status, 200);
|
|
92
|
+
const row = rowOf(html, 'site-one');
|
|
93
|
+
for (const slot of ['ddev', 'composer', 'tests', 'screens']) {
|
|
94
|
+
const menu = menuOf(row, slot);
|
|
95
|
+
assert.ok(menu, `${slot}: the row has the menu`);
|
|
96
|
+
assert.match(menu, new RegExp(`hx-get="/fragments/row-menu/${slot}/dev/site-one"`), `${slot}: the shell asks the core route for its body`);
|
|
97
|
+
assert.match(menu, /class="act-menu-lazy[^"]*" hx-get=/, `${slot}: the dropdown is the lazy shell`);
|
|
98
|
+
assert.match(menu, /hx-trigger="menu-opened/, `${slot}: fetched when the menu opens`);
|
|
99
|
+
assert.match(menu, /hx-target="this" hx-swap="innerHTML"/, `${slot}: into the dropdown itself`);
|
|
100
|
+
assert.match(menu, /<ul class="uk-nav uk-dropdown-nav"><li class="uk-nav-header">…<\/li><\/ul>/, `${slot}: a placeholder, not the menu`);
|
|
101
|
+
}
|
|
102
|
+
// The state words stay on the row: DDEV counts its commands, COMPOSER says locked, Tests says what it can do.
|
|
103
|
+
assert.match(menuOf(row, 'ddev'), /<span class="act-status uk-text-meta">\d+ commands<\/span>/, 'the DDEV caption counts the commands the body will hold');
|
|
104
|
+
assert.match(menuOf(row, 'ddev'), /class="act-menu-lazy ddev-dropdown-wide"/, 'the DDEV width class rides on the shell');
|
|
105
|
+
assert.match(menuOf(row, 'composer'), /<span class="act-status uk-text-meta">locked<\/span>/);
|
|
106
|
+
assert.match(menuOf(row, 'composer'), /class="act-menu-lazy composer-dropdown composer-dropdown-wide"/, 'a site workspace installs: the wide, two-column menu');
|
|
107
|
+
assert.match(menuOf(row, 'tests'), /<span class="act-status uk-text-meta">not set up<\/span>/);
|
|
108
|
+
assert.match(menuOf(row, 'tests'), /uk-button-secondary/, 'the Tests button as it was');
|
|
109
|
+
assert.match(menuOf(row, 'screens'), /hx-trigger="menu-opened, load, every 30s, screens-changed from:body"/, 'SCREENS still polls: the hand and the agent badge ride on its answer');
|
|
110
|
+
assert.match(menuOf(row, 'screens'), /<span class="act-status uk-text-meta">none<\/span>/);
|
|
111
|
+
// Nothing of the bodies is on the page.
|
|
112
|
+
for (const inner of ['/actions/ddev-restart', '/actions/ddev-describe', '/actions/composer-install', '/actions/testing-configure', '/actions/checks-plan', 'composer-package', 'checks-sub', 'ddev-nav-columns']) {
|
|
113
|
+
assert.ok(!row.includes(inner), `${inner} is not on the page before a menu opens`);
|
|
114
|
+
}
|
|
115
|
+
// A folder with no DDEV config has the Configure DDEV button and no DDEV menu; no composer.json, no COMPOSER.
|
|
116
|
+
// No DDEV and no backup here, so nothing on the row posts with its name: found by its Configure DDEV link.
|
|
117
|
+
const plain = rowOf(html, 'plain-folder', '/fragments/projects/ddev-config/dev/plain-folder');
|
|
118
|
+
assert.match(plain, /act-ddev-config/);
|
|
119
|
+
assert.ok(!menuOf(plain, 'ddev') && !menuOf(plain, 'composer'));
|
|
120
|
+
});
|
|
121
|
+
|
|
122
|
+
test('the DDEV body for one project holds the verbs the row used to carry, arranged, in the DDEV list', async () => {
|
|
123
|
+
const { status, html } = await text('/fragments/row-menu/ddev/dev/site-one');
|
|
124
|
+
assert.strictEqual(status, 200);
|
|
125
|
+
assert.match(html, /^<ul class="uk-nav uk-dropdown-nav ddev-nav-columns">/, 'the list the DDEV CSS lays out');
|
|
126
|
+
assert.match(html, /<li class="uk-nav-header" data-group="project">site-one<\/li>/);
|
|
127
|
+
for (const verb of ['/actions/ddev-start', '/actions/ddev-restart', '/actions/ddev-describe', '/actions/ddev-logs', '/actions/ddev-mutagen-sync', '/actions/ddev-diagnose', '/fragments/projects/ddev-config/dev/site-one', '/fragments/projects/addons/dev/site-one']) {
|
|
128
|
+
assert.ok(html.includes(`"${verb}"`), `${verb} is in the body`);
|
|
129
|
+
}
|
|
130
|
+
assert.match(html, /hx-vals='\{"workspace":"dev","projectName":"site-one"\}' hx-target="#webship-workspace-output"/, 'the items post for this project into the output');
|
|
131
|
+
assert.match(html, /class="uk-text-danger arm-step" hx-post="\/actions\/ddev-mutagen-reset"[^>]*hx-trigger="confirmed-remove"/, 'the confirm dialog still arms the destructive items');
|
|
132
|
+
assert.match(html, /Canonical URL/);
|
|
133
|
+
// Stopped: the running-only items (xdebug, share, export) are not offered — the state is read for this project.
|
|
134
|
+
assert.ok(!html.includes('/actions/ddev-xdebug-on') && !html.includes('/actions/ddev-share'), 'a stopped project is not offered the running-only verbs');
|
|
135
|
+
// The caption on the row counted what the body holds.
|
|
136
|
+
const page = (await text('/dev')).html;
|
|
137
|
+
const count = Number(/(\d+) commands/.exec(menuOf(rowOf(page, 'site-one'), 'ddev'))[1]);
|
|
138
|
+
assert.strictEqual((html.match(/<a /g) || []).length, count, 'the row counts the commands the body holds');
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
test('the COMPOSER body for a site is the two-column menu with its commands, the package field and the scripts', async () => {
|
|
142
|
+
const { status, html } = await text('/fragments/row-menu/composer/dev/site-one');
|
|
143
|
+
assert.strictEqual(status, 200);
|
|
144
|
+
assert.match(html, /^<div class="composer-columns">/);
|
|
145
|
+
assert.match(html, /Composer · acme\/site-one/);
|
|
146
|
+
assert.match(html, /Runs as ddev composer in this project\./);
|
|
147
|
+
for (const verb of ['/actions/composer-install', '/actions/composer-update', '/actions/composer-validate', '/actions/composer-require']) assert.ok(html.includes(`"${verb}"`), verb);
|
|
148
|
+
assert.match(html, /hx-include="closest \.composer-package"/);
|
|
149
|
+
assert.match(html, /Scripts in composer\.json/);
|
|
150
|
+
assert.match(html, /"script":"site:check"/, 'the custom script from composer.json is offered');
|
|
151
|
+
assert.ok(!html.includes('post-install-cmd'), 'an event hook is not a script to run');
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test('the Tests body for a site offers the test users, the environment and the nested checks', async () => {
|
|
155
|
+
const { status, html } = await text('/fragments/row-menu/tests/dev/site-one');
|
|
156
|
+
assert.strictEqual(status, 200);
|
|
157
|
+
assert.match(html, /^<ul class="uk-nav uk-dropdown-nav" uk-nav="multiple: true">/);
|
|
158
|
+
assert.match(html, /hx-post="\/actions\/add-users"/);
|
|
159
|
+
assert.match(html, /hx-post="\/actions\/cancel-users"/);
|
|
160
|
+
assert.match(html, /<li class="uk-nav-header" data-group="suite">not set up<\/li>/);
|
|
161
|
+
assert.match(html, /hx-post="\/actions\/testing-configure"[^>]*>[\s\S]*?Set up the testing environment/);
|
|
162
|
+
assert.ok(!html.includes('/actions/testing-run'), 'no stack, nothing to run');
|
|
163
|
+
assert.match(html, /<li class="uk-parent act-menu-checks" data-entry="checks">/);
|
|
164
|
+
assert.match(html, /hx-post="\/actions\/checks-plan"/);
|
|
165
|
+
assert.match(html, /<ul class="uk-nav-sub checks-sub">/);
|
|
166
|
+
assert.match(html, /<span class="checks-alias">js<\/span> <code class="checks-script">lint:js<\/code>/, 'each alias against the script it runs');
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test('the Tests body for a package is the checks alone, and a plain folder in a site workspace still gets the environment', async () => {
|
|
170
|
+
const pkg = await text('/fragments/row-menu/tests/products/pkg-one');
|
|
171
|
+
assert.strictEqual(pkg.status, 200);
|
|
172
|
+
assert.match(pkg.html, /act-menu-checks/);
|
|
173
|
+
assert.match(pkg.html, /2 declared/);
|
|
174
|
+
assert.match(pkg.html, /<span class="checks-alias">css<\/span>/);
|
|
175
|
+
assert.ok(!pkg.html.includes('/actions/testing-configure') && !pkg.html.includes('/actions/add-users'), 'a packages workspace has no suite and no test users');
|
|
176
|
+
const page = (await text('/products')).html;
|
|
177
|
+
assert.match(menuOf(rowOf(page, 'pkg-one'), 'tests'), /<span class="act-status uk-text-meta">(2 declared|deps missing)<\/span>/, 'the caption says what the package declares');
|
|
178
|
+
const plain = await text('/fragments/row-menu/tests/dev/plain-folder');
|
|
179
|
+
assert.strictEqual(plain.status, 200);
|
|
180
|
+
assert.match(plain.html, /hx-post="\/actions\/testing-configure"/);
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
test('the SCREENS body lists the sessions (none here), the ways to start one, and swaps the row\'s badges out of band', async () => {
|
|
184
|
+
const { status, html } = await text('/fragments/row-menu/screens/dev/site-one');
|
|
185
|
+
assert.strictEqual(status, 200);
|
|
186
|
+
assert.match(html, /^<ul class="uk-nav uk-dropdown-nav">/);
|
|
187
|
+
assert.match(html, /No screens in this project/);
|
|
188
|
+
assert.match(html, /New screen here…/);
|
|
189
|
+
assert.match(html, /Claude Code here…/);
|
|
190
|
+
assert.match(html, /href="\/screens"/);
|
|
191
|
+
assert.match(html, /<span class="screens-hand" id="screens-hand-dev-site-one" hx-swap-oob="true">/, 'the hand on the button');
|
|
192
|
+
assert.match(html, /id="agent-badge-dev-site-one" hx-swap-oob="true"/, 'the agent badge in the title');
|
|
193
|
+
assert.match(html, /id="plugin-badges-dev-site-one" hx-swap-oob="true"/, 'the plugins\' badges');
|
|
194
|
+
// The legacy fragment the old rows polled still answers, with the list's inner HTML.
|
|
195
|
+
const legacy = await text('/fragments/projects/screens/dev/site-one');
|
|
196
|
+
assert.strictEqual(legacy.status, 200);
|
|
197
|
+
assert.match(legacy.html, /^\s*<li class="uk-nav-header">No screens in this project<\/li>/);
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
test('a project that is not there, a menu the row would not draw, and a slot that is not a menu are 404', async () => {
|
|
201
|
+
for (const [p, why] of [
|
|
202
|
+
['/fragments/row-menu/ddev/dev/no-such-project', 'no such project'],
|
|
203
|
+
['/fragments/row-menu/ddev/dev/plain-folder', 'not a DDEV project'],
|
|
204
|
+
['/fragments/row-menu/composer/dev/plain-folder', 'no composer.json'],
|
|
205
|
+
['/fragments/row-menu/ddev/products/pkg-one', 'a packages workspace has no DDEV'],
|
|
206
|
+
['/fragments/row-menu/more/dev/site-one', 'More is drawn inline, not served'],
|
|
207
|
+
['/fragments/row-menu/ddev/nope/site-one', 'no such workspace'],
|
|
208
|
+
['/fragments/row-menu/ddev/dev/site-one?body=/fragments/p/x/menu', 'a core slot takes no plugin body'],
|
|
209
|
+
['/fragments/row-menu/x-plugin/dev/site-one', 'a plugin slot nobody registered'],
|
|
210
|
+
]) {
|
|
211
|
+
const { status, html } = await text(p);
|
|
212
|
+
assert.strictEqual(status, 404, `${p}: ${why}`);
|
|
213
|
+
assert.match(html, /^<ul class="uk-nav uk-dropdown-nav"><li class="uk-nav-header">/, `${p}: answered as a menu line, so the shell shows why`);
|
|
214
|
+
}
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
test('the Buttons settings page learns the lazy menus\' entries without anyone opening one', async () => {
|
|
218
|
+
const { status, html } = await text('/settings/buttons');
|
|
219
|
+
assert.strictEqual(status, 200);
|
|
220
|
+
// Both menus whose bodies load on open are listed with what they hold, learned from the first project
|
|
221
|
+
// that carries them (learnCoreMenus) — the seen record this tree started with was empty.
|
|
222
|
+
assert.match(html, /composer/i);
|
|
223
|
+
// The record is written a moment after it changes (row-layout's flushSeenSoon).
|
|
224
|
+
await new Promise((r) => setTimeout(r, 2500));
|
|
225
|
+
const seen = JSON.parse(fs.readFileSync(path.join(root, 'core/config/row-actions.seen.json'), 'utf8'));
|
|
226
|
+
assert.ok(seen.composer && Object.keys(seen.composer.groups).length > 0, 'COMPOSER has groups on record');
|
|
227
|
+
assert.ok(seen.tests && Object.keys(seen.tests.groups).length > 0, 'Tests has groups on record');
|
|
228
|
+
assert.ok(seen.ddev && Object.keys(seen.ddev.groups).length > 0, 'DDEV is on record from the rows themselves');
|
|
229
|
+
});
|
|
@@ -43,7 +43,12 @@ test('a dropdown that is being typed in or paged stays open', () => {
|
|
|
43
43
|
});
|
|
44
44
|
|
|
45
45
|
test('the row polls slowly, and catches up the moment anything happens', () => {
|
|
46
|
-
|
|
46
|
+
// The row's SCREENS is the shared lazy shell (plugin-loader's menuShellHtml): its body is
|
|
47
|
+
// /fragments/row-menu/screens/<ws>/<project>, fetched on `menu-opened` by the shell itself and, for the
|
|
48
|
+
// badges that ride on the answer, on the triggers the row adds.
|
|
49
|
+
const shell = server.match(/menuShellHtml\('screens', \{[\s\S]*?triggers: '([^']+)'/)[1];
|
|
50
|
+
const loader = fs.readFileSync(path.join(__dirname, '..', '..', 'plugin-loader.js'), 'utf8');
|
|
51
|
+
const menu = `${loader.match(/const trigger = \['([a-z-]+)'/)[1]}, ${shell}`;
|
|
47
52
|
assert.match(menu, /every 30s/, 'the background poll is the cheap one');
|
|
48
53
|
assert.ok(!/every 15s/.test(menu), 'not twice a minute per row, forever');
|
|
49
54
|
assert.match(menu, /screens-changed from:body/, 'a session starting or stopping reaches it');
|
|
@@ -79,6 +84,6 @@ test('a session in the menu says what it is doing', () => {
|
|
|
79
84
|
assert.match(items, /dead \? '<span class="uk-label">dead<\/span>'/, 'a session whose process is gone opens onto nothing');
|
|
80
85
|
assert.match(items, /isAgent \? iconHtml\('tabler:robot-face'/);
|
|
81
86
|
// The name is `<workspace>--<project>--<what>`, and the first two are the row it is already on.
|
|
82
|
-
assert.match(items, /s\.session\.startsWith\(`\$\{
|
|
87
|
+
assert.match(items, /s\.session\.startsWith\(`\$\{ws\}--\$\{project\}--`\)/);
|
|
83
88
|
assert.match(items, /title="\$\{esc\(s\.session\)\}/, 'the whole name is still one hover away');
|
|
84
89
|
});
|
|
@@ -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",
|
|
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": {
|