@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.
@@ -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
+ }