@signalk/app-dock 0.1.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/.github/workflows/ci.yml +11 -0
- package/.github/workflows/publish.yml +33 -0
- package/.prettierignore +6 -0
- package/.prettierrc +6 -0
- package/LICENSE +190 -0
- package/README.md +83 -0
- package/eslint.config.js +55 -0
- package/package.json +40 -0
- package/plugin/index.js +180 -0
- package/public/540.js +2 -0
- package/public/540.js.LICENSE.txt +9 -0
- package/public/805.js +1 -0
- package/public/app-icon.svg +29 -0
- package/public/config.html +469 -0
- package/public/dock.css +412 -0
- package/public/dock.js +456 -0
- package/public/icon-settings.svg +21 -0
- package/public/index.html +41 -0
- package/public/main.js +1 -0
- package/public/remoteEntry.js +1 -0
- package/src/configpanel/PluginConfigurationPanel.js +441 -0
- package/src/configpanel/index.js +1 -0
- package/test/plugin.test.js +394 -0
- package/webpack.config.js +39 -0
|
@@ -0,0 +1,394 @@
|
|
|
1
|
+
'use strict'
|
|
2
|
+
|
|
3
|
+
const { describe, it } = require('node:test')
|
|
4
|
+
const assert = require('node:assert/strict')
|
|
5
|
+
const path = require('path')
|
|
6
|
+
const fs = require('fs')
|
|
7
|
+
|
|
8
|
+
const pluginFactory = require('../plugin/index.js')
|
|
9
|
+
|
|
10
|
+
function createMockApp(webapps = []) {
|
|
11
|
+
return {
|
|
12
|
+
webapps,
|
|
13
|
+
debug: () => {},
|
|
14
|
+
error: () => {},
|
|
15
|
+
savePluginOptions: (opts, cb) => cb(null)
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
const MOCK_WEBAPPS = [
|
|
20
|
+
{
|
|
21
|
+
name: '@signalk/freeboard-sk',
|
|
22
|
+
signalk: { displayName: 'Freeboard-SK', appIcon: './assets/icons/icon-72x72.png' }
|
|
23
|
+
},
|
|
24
|
+
{
|
|
25
|
+
name: '@mxtommy/kip',
|
|
26
|
+
signalk: { displayName: 'KIP Instrument MFD', appIcon: 'assets/icon-72x72.png' }
|
|
27
|
+
},
|
|
28
|
+
{
|
|
29
|
+
name: 'signalk-app-dock',
|
|
30
|
+
signalk: { displayName: 'App Dock' }
|
|
31
|
+
},
|
|
32
|
+
{
|
|
33
|
+
name: '@signalk/server-admin-ui',
|
|
34
|
+
signalk: { displayName: 'Admin UI' }
|
|
35
|
+
}
|
|
36
|
+
]
|
|
37
|
+
|
|
38
|
+
describe('plugin identity', () => {
|
|
39
|
+
it('returns correct id and name', () => {
|
|
40
|
+
const plugin = pluginFactory(createMockApp())
|
|
41
|
+
assert.equal(plugin.id, 'signalk-app-dock')
|
|
42
|
+
assert.equal(plugin.name, 'App Dock')
|
|
43
|
+
})
|
|
44
|
+
|
|
45
|
+
it('has required plugin interface methods', () => {
|
|
46
|
+
const plugin = pluginFactory(createMockApp())
|
|
47
|
+
assert.equal(typeof plugin.start, 'function')
|
|
48
|
+
assert.equal(typeof plugin.stop, 'function')
|
|
49
|
+
assert.equal(typeof plugin.registerWithRouter, 'function')
|
|
50
|
+
})
|
|
51
|
+
})
|
|
52
|
+
|
|
53
|
+
describe('schema', () => {
|
|
54
|
+
it('defines all expected properties', () => {
|
|
55
|
+
const plugin = pluginFactory(createMockApp())
|
|
56
|
+
const props = Object.keys(plugin.schema.properties)
|
|
57
|
+
assert.ok(props.includes('position'))
|
|
58
|
+
assert.ok(props.includes('triggerCorner'))
|
|
59
|
+
assert.ok(props.includes('iframeMode'))
|
|
60
|
+
assert.ok(props.includes('iconSize'))
|
|
61
|
+
assert.ok(props.includes('magnification'))
|
|
62
|
+
assert.ok(props.includes('magnificationScale'))
|
|
63
|
+
assert.ok(props.includes('apps'))
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
it('position enum contains all four edges', () => {
|
|
67
|
+
const plugin = pluginFactory(createMockApp())
|
|
68
|
+
const posEnum = plugin.schema.properties.position.enum
|
|
69
|
+
assert.deepEqual(posEnum, ['bottom', 'top', 'left', 'right'])
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('apps items require url', () => {
|
|
73
|
+
const plugin = pluginFactory(createMockApp())
|
|
74
|
+
const items = plugin.schema.properties.apps.items
|
|
75
|
+
assert.deepEqual(items.required, ['url'])
|
|
76
|
+
})
|
|
77
|
+
})
|
|
78
|
+
|
|
79
|
+
describe('webapp discovery', () => {
|
|
80
|
+
it('filters out signalk-app-dock and admin-ui from /webapps', (t, done) => {
|
|
81
|
+
const app = createMockApp(MOCK_WEBAPPS)
|
|
82
|
+
const plugin = pluginFactory(app)
|
|
83
|
+
const routes = {}
|
|
84
|
+
const router = {
|
|
85
|
+
get: (path, handler) => {
|
|
86
|
+
routes[path] = handler
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
plugin.registerWithRouter(router)
|
|
90
|
+
|
|
91
|
+
const res = {
|
|
92
|
+
json: (data) => {
|
|
93
|
+
assert.equal(data.length, 2)
|
|
94
|
+
assert.ok(data.every((w) => w.name !== 'signalk-app-dock'))
|
|
95
|
+
assert.ok(data.every((w) => w.name !== '@signalk/server-admin-ui'))
|
|
96
|
+
done()
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
routes['/webapps']({}, res)
|
|
100
|
+
})
|
|
101
|
+
|
|
102
|
+
it('maps webapp metadata correctly', (t, done) => {
|
|
103
|
+
const app = createMockApp(MOCK_WEBAPPS)
|
|
104
|
+
const plugin = pluginFactory(app)
|
|
105
|
+
const routes = {}
|
|
106
|
+
const router = {
|
|
107
|
+
get: (path, handler) => {
|
|
108
|
+
routes[path] = handler
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
plugin.registerWithRouter(router)
|
|
112
|
+
|
|
113
|
+
const res = {
|
|
114
|
+
json: (data) => {
|
|
115
|
+
const fb = data.find((w) => w.name === '@signalk/freeboard-sk')
|
|
116
|
+
assert.equal(fb.label, 'Freeboard-SK')
|
|
117
|
+
assert.equal(fb.url, '/@signalk/freeboard-sk/')
|
|
118
|
+
assert.equal(fb.icon, '/@signalk/freeboard-sk/assets/icons/icon-72x72.png')
|
|
119
|
+
done()
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
routes['/webapps']({}, res)
|
|
123
|
+
})
|
|
124
|
+
|
|
125
|
+
it('handles missing signalk metadata gracefully', (t, done) => {
|
|
126
|
+
const app = createMockApp([{ name: 'bare-plugin' }])
|
|
127
|
+
const plugin = pluginFactory(app)
|
|
128
|
+
const routes = {}
|
|
129
|
+
const router = {
|
|
130
|
+
get: (path, handler) => {
|
|
131
|
+
routes[path] = handler
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
plugin.registerWithRouter(router)
|
|
135
|
+
|
|
136
|
+
const res = {
|
|
137
|
+
json: (data) => {
|
|
138
|
+
assert.equal(data.length, 1)
|
|
139
|
+
assert.equal(data[0].label, 'bare-plugin')
|
|
140
|
+
assert.equal(data[0].icon, null)
|
|
141
|
+
done()
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
routes['/webapps']({}, res)
|
|
145
|
+
})
|
|
146
|
+
|
|
147
|
+
it('returns empty array when app.webapps is undefined', (t, done) => {
|
|
148
|
+
const app = createMockApp()
|
|
149
|
+
delete app.webapps
|
|
150
|
+
const plugin = pluginFactory(app)
|
|
151
|
+
const routes = {}
|
|
152
|
+
const router = {
|
|
153
|
+
get: (path, handler) => {
|
|
154
|
+
routes[path] = handler
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
plugin.registerWithRouter(router)
|
|
158
|
+
|
|
159
|
+
const res = {
|
|
160
|
+
json: (data) => {
|
|
161
|
+
assert.deepEqual(data, [])
|
|
162
|
+
done()
|
|
163
|
+
}
|
|
164
|
+
}
|
|
165
|
+
routes['/webapps']({}, res)
|
|
166
|
+
})
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
describe('settings endpoint', () => {
|
|
170
|
+
it('returns plugin settings with resolved apps', (t, done) => {
|
|
171
|
+
const app = createMockApp()
|
|
172
|
+
const plugin = pluginFactory(app)
|
|
173
|
+
plugin.start({ position: 'left', iconSize: 64, apps: [] })
|
|
174
|
+
|
|
175
|
+
const routes = {}
|
|
176
|
+
const router = {
|
|
177
|
+
get: (path, handler) => {
|
|
178
|
+
routes[path] = handler
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
plugin.registerWithRouter(router)
|
|
182
|
+
|
|
183
|
+
const res = {
|
|
184
|
+
json: (data) => {
|
|
185
|
+
assert.equal(data.position, 'left')
|
|
186
|
+
assert.equal(data.iconSize, 64)
|
|
187
|
+
assert.ok(Array.isArray(data.apps))
|
|
188
|
+
done()
|
|
189
|
+
}
|
|
190
|
+
}
|
|
191
|
+
routes['/settings']({}, res)
|
|
192
|
+
})
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
describe('auto-discovery on start', () => {
|
|
196
|
+
it('merges discovered webapps into configured apps after delay', (t, done) => {
|
|
197
|
+
const app = createMockApp(MOCK_WEBAPPS)
|
|
198
|
+
let savedOptions = null
|
|
199
|
+
app.savePluginOptions = (opts, cb) => {
|
|
200
|
+
savedOptions = opts
|
|
201
|
+
cb(null)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const plugin = pluginFactory(app)
|
|
205
|
+
|
|
206
|
+
const originalSetTimeout = globalThis.setTimeout
|
|
207
|
+
globalThis.setTimeout = (fn) => fn()
|
|
208
|
+
try {
|
|
209
|
+
plugin.start({ apps: [] })
|
|
210
|
+
} finally {
|
|
211
|
+
globalThis.setTimeout = originalSetTimeout
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
assert.ok(savedOptions)
|
|
215
|
+
assert.ok(savedOptions.apps.length >= 2)
|
|
216
|
+
assert.ok(savedOptions.apps.some((a) => a.url === '/@signalk/freeboard-sk/'))
|
|
217
|
+
assert.ok(savedOptions.apps.some((a) => a.url === '/@mxtommy/kip/'))
|
|
218
|
+
done()
|
|
219
|
+
})
|
|
220
|
+
|
|
221
|
+
it('does not duplicate already-configured apps', (t, done) => {
|
|
222
|
+
const app = createMockApp(MOCK_WEBAPPS)
|
|
223
|
+
let savedOptions = null
|
|
224
|
+
app.savePluginOptions = (opts, cb) => {
|
|
225
|
+
savedOptions = opts
|
|
226
|
+
cb(null)
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const plugin = pluginFactory(app)
|
|
230
|
+
const existing = [{ enabled: true, url: '/@signalk/freeboard-sk/', label: 'FB', icon: '', color: '' }]
|
|
231
|
+
|
|
232
|
+
const originalSetTimeout = globalThis.setTimeout
|
|
233
|
+
globalThis.setTimeout = (fn) => fn()
|
|
234
|
+
try {
|
|
235
|
+
plugin.start({ apps: existing })
|
|
236
|
+
} finally {
|
|
237
|
+
globalThis.setTimeout = originalSetTimeout
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
const fbEntries = savedOptions.apps.filter((a) => a.url === '/@signalk/freeboard-sk/')
|
|
241
|
+
assert.equal(fbEntries.length, 1)
|
|
242
|
+
assert.equal(fbEntries[0].label, 'FB')
|
|
243
|
+
done()
|
|
244
|
+
})
|
|
245
|
+
|
|
246
|
+
it('skips save when no new apps are discovered', () => {
|
|
247
|
+
const app = createMockApp(MOCK_WEBAPPS)
|
|
248
|
+
let saveCalled = false
|
|
249
|
+
app.savePluginOptions = () => {
|
|
250
|
+
saveCalled = true
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
const plugin = pluginFactory(app)
|
|
254
|
+
const allConfigured = [
|
|
255
|
+
{ enabled: true, url: '/@signalk/freeboard-sk/', label: '', icon: '', color: '' },
|
|
256
|
+
{ enabled: true, url: '/@mxtommy/kip/', label: '', icon: '', color: '' }
|
|
257
|
+
]
|
|
258
|
+
|
|
259
|
+
const originalSetTimeout = globalThis.setTimeout
|
|
260
|
+
globalThis.setTimeout = (fn) => fn()
|
|
261
|
+
try {
|
|
262
|
+
plugin.start({ apps: allConfigured })
|
|
263
|
+
} finally {
|
|
264
|
+
globalThis.setTimeout = originalSetTimeout
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
assert.equal(saveCalled, false)
|
|
268
|
+
})
|
|
269
|
+
|
|
270
|
+
it('respects enabled:false when resolving apps', () => {
|
|
271
|
+
const app = createMockApp(MOCK_WEBAPPS)
|
|
272
|
+
app.savePluginOptions = (_, cb) => cb(null)
|
|
273
|
+
const plugin = pluginFactory(app)
|
|
274
|
+
|
|
275
|
+
const configured = [
|
|
276
|
+
{ enabled: false, url: '/@signalk/freeboard-sk/', label: '', icon: '', color: '' },
|
|
277
|
+
{ enabled: true, url: '/@mxtommy/kip/', label: '', icon: '', color: '' }
|
|
278
|
+
]
|
|
279
|
+
|
|
280
|
+
const originalSetTimeout = globalThis.setTimeout
|
|
281
|
+
globalThis.setTimeout = (fn) => fn()
|
|
282
|
+
try {
|
|
283
|
+
plugin.start({ apps: configured })
|
|
284
|
+
} finally {
|
|
285
|
+
globalThis.setTimeout = originalSetTimeout
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
const routes = {}
|
|
289
|
+
const router = {
|
|
290
|
+
get: (path, handler) => {
|
|
291
|
+
routes[path] = handler
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
plugin.registerWithRouter(router)
|
|
295
|
+
|
|
296
|
+
let resolved
|
|
297
|
+
routes['/settings'](
|
|
298
|
+
{},
|
|
299
|
+
{
|
|
300
|
+
json: (d) => {
|
|
301
|
+
resolved = d
|
|
302
|
+
}
|
|
303
|
+
}
|
|
304
|
+
)
|
|
305
|
+
assert.ok(!resolved.apps.some((a) => a.url === '/@signalk/freeboard-sk/'))
|
|
306
|
+
assert.ok(resolved.apps.some((a) => a.url === '/@mxtommy/kip/'))
|
|
307
|
+
})
|
|
308
|
+
|
|
309
|
+
it('uses discovered label/icon when config overrides are empty', () => {
|
|
310
|
+
const app = createMockApp(MOCK_WEBAPPS)
|
|
311
|
+
app.savePluginOptions = (_, cb) => cb(null)
|
|
312
|
+
const plugin = pluginFactory(app)
|
|
313
|
+
|
|
314
|
+
const configured = [{ enabled: true, url: '/@mxtommy/kip/', label: '', icon: '', color: '' }]
|
|
315
|
+
|
|
316
|
+
const originalSetTimeout = globalThis.setTimeout
|
|
317
|
+
globalThis.setTimeout = (fn) => fn()
|
|
318
|
+
try {
|
|
319
|
+
plugin.start({ apps: configured })
|
|
320
|
+
} finally {
|
|
321
|
+
globalThis.setTimeout = originalSetTimeout
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
const routes = {}
|
|
325
|
+
const router = {
|
|
326
|
+
get: (path, handler) => {
|
|
327
|
+
routes[path] = handler
|
|
328
|
+
}
|
|
329
|
+
}
|
|
330
|
+
plugin.registerWithRouter(router)
|
|
331
|
+
|
|
332
|
+
let resolved
|
|
333
|
+
routes['/settings'](
|
|
334
|
+
{},
|
|
335
|
+
{
|
|
336
|
+
json: (d) => {
|
|
337
|
+
resolved = d
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
)
|
|
341
|
+
const kip = resolved.apps.find((a) => a.url === '/@mxtommy/kip/')
|
|
342
|
+
assert.equal(kip.label, 'KIP Instrument MFD')
|
|
343
|
+
assert.equal(kip.icon, '/@mxtommy/kip/assets/icon-72x72.png')
|
|
344
|
+
})
|
|
345
|
+
})
|
|
346
|
+
|
|
347
|
+
describe('package.json', () => {
|
|
348
|
+
const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf-8'))
|
|
349
|
+
|
|
350
|
+
it('has signalk-node-server-plugin keyword', () => {
|
|
351
|
+
assert.ok(pkg.keywords.includes('signalk-node-server-plugin'))
|
|
352
|
+
})
|
|
353
|
+
|
|
354
|
+
it('has signalk-webapp keyword', () => {
|
|
355
|
+
assert.ok(pkg.keywords.includes('signalk-webapp'))
|
|
356
|
+
})
|
|
357
|
+
|
|
358
|
+
it('has signalk-plugin-configurator keyword', () => {
|
|
359
|
+
assert.ok(pkg.keywords.includes('signalk-plugin-configurator'))
|
|
360
|
+
})
|
|
361
|
+
|
|
362
|
+
it('main points to plugin/index.js', () => {
|
|
363
|
+
assert.equal(pkg.main, 'plugin/index.js')
|
|
364
|
+
assert.ok(fs.existsSync(path.join(__dirname, '..', pkg.main)))
|
|
365
|
+
})
|
|
366
|
+
})
|
|
367
|
+
|
|
368
|
+
describe('public files', () => {
|
|
369
|
+
const publicDir = path.join(__dirname, '..', 'public')
|
|
370
|
+
|
|
371
|
+
it('index.html exists', () => {
|
|
372
|
+
assert.ok(fs.existsSync(path.join(publicDir, 'index.html')))
|
|
373
|
+
})
|
|
374
|
+
|
|
375
|
+
it('dock.js exists', () => {
|
|
376
|
+
assert.ok(fs.existsSync(path.join(publicDir, 'dock.js')))
|
|
377
|
+
})
|
|
378
|
+
|
|
379
|
+
it('dock.css exists', () => {
|
|
380
|
+
assert.ok(fs.existsSync(path.join(publicDir, 'dock.css')))
|
|
381
|
+
})
|
|
382
|
+
|
|
383
|
+
it('remoteEntry.js exists (Module Federation)', () => {
|
|
384
|
+
assert.ok(fs.existsSync(path.join(publicDir, 'remoteEntry.js')))
|
|
385
|
+
})
|
|
386
|
+
|
|
387
|
+
it('app-icon.svg exists', () => {
|
|
388
|
+
assert.ok(fs.existsSync(path.join(publicDir, 'app-icon.svg')))
|
|
389
|
+
})
|
|
390
|
+
|
|
391
|
+
it('icon-settings.svg exists', () => {
|
|
392
|
+
assert.ok(fs.existsSync(path.join(publicDir, 'icon-settings.svg')))
|
|
393
|
+
})
|
|
394
|
+
})
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
const path = require('path')
|
|
2
|
+
const { ModuleFederationPlugin } = require('webpack').container
|
|
3
|
+
const packageJson = require('./package.json')
|
|
4
|
+
|
|
5
|
+
module.exports = {
|
|
6
|
+
entry: './src/configpanel/index',
|
|
7
|
+
mode: 'production',
|
|
8
|
+
output: {
|
|
9
|
+
path: path.resolve(__dirname, 'public'),
|
|
10
|
+
clean: false
|
|
11
|
+
},
|
|
12
|
+
module: {
|
|
13
|
+
rules: [
|
|
14
|
+
{
|
|
15
|
+
test: /\.jsx?$/,
|
|
16
|
+
loader: 'babel-loader',
|
|
17
|
+
exclude: /node_modules/,
|
|
18
|
+
options: { presets: ['@babel/preset-react'] }
|
|
19
|
+
}
|
|
20
|
+
]
|
|
21
|
+
},
|
|
22
|
+
resolve: {
|
|
23
|
+
extensions: ['.js', '.jsx']
|
|
24
|
+
},
|
|
25
|
+
plugins: [
|
|
26
|
+
new ModuleFederationPlugin({
|
|
27
|
+
name: packageJson.name.replace(/[-@/]/g, '_'),
|
|
28
|
+
library: { type: 'var', name: packageJson.name.replace(/[-@/]/g, '_') },
|
|
29
|
+
filename: 'remoteEntry.js',
|
|
30
|
+
exposes: {
|
|
31
|
+
'./PluginConfigurationPanel': './src/configpanel/PluginConfigurationPanel'
|
|
32
|
+
},
|
|
33
|
+
shared: {
|
|
34
|
+
react: { singleton: true, requiredVersion: '^19' },
|
|
35
|
+
'react-dom': { singleton: true, requiredVersion: '^19' }
|
|
36
|
+
}
|
|
37
|
+
})
|
|
38
|
+
]
|
|
39
|
+
}
|