@webship.co/workspace 1.0.0-rc1 → 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 +46 -1
- package/core/workspace-app/package.json +1 -1
- 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/checks.feature +22 -6
- package/core/workspace-app/tests/features/clone-project.feature +1 -0
- package/core/workspace-app/tests/features/project-actions.feature +11 -5
- package/core/workspace-app/tests/unit/claude-code-here.test.js +1 -1
- package/core/workspace-app/tests/unit/plugin-system.test.js +38 -1
- 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/package.json +1 -1
|
@@ -3,38 +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.
|
|
34
48
|
@needs-testing-stack
|
|
35
49
|
Scenario: A site keeps its testing verbs and gains the checks beside them
|
|
36
50
|
Given I am on "/dev"
|
|
37
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
|
|
38
54
|
And "[hx-post='/actions/testing-configure']" should be attached
|
|
39
55
|
And "[hx-post='/actions/testing-run']" should be attached
|
|
40
56
|
And ".act-menu-tests .act-menu-checks" should be attached
|
|
@@ -4,6 +4,7 @@ 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
|
|
@@ -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,11 +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.
|
|
18
22
|
@needs-testing-stack
|
|
19
23
|
Scenario: The Tests menu offers setting the environment up and running the suite
|
|
20
24
|
Given I am on "/dev"
|
|
21
|
-
|
|
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
|
|
22
28
|
And "[hx-post='/actions/testing-run']" should be attached
|
|
23
29
|
|
|
24
30
|
# The state under the button is the answer to "is there anything to look at yet" — it says
|
|
@@ -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
|
|
|
@@ -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');
|
|
@@ -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');
|
|
@@ -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
|
});
|
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": {
|