@signalk/app-dock 1.2.1 → 1.3.1

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.
@@ -1,696 +0,0 @@
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
- readPluginOptions: () => ({}),
17
- registerPutHandler: () => {},
18
- handleMessage: () => {},
19
- getSelfPath: () => null
20
- }
21
- }
22
-
23
- const MOCK_WEBAPPS = [
24
- {
25
- name: '@signalk/freeboard-sk',
26
- signalk: { displayName: 'Freeboard-SK', appIcon: './assets/icons/icon-72x72.png' }
27
- },
28
- {
29
- name: '@mxtommy/kip',
30
- signalk: { displayName: 'KIP Instrument MFD', appIcon: 'assets/icon-72x72.png' }
31
- },
32
- {
33
- name: '@signalk/app-dock',
34
- signalk: { displayName: 'App Dock' }
35
- },
36
- {
37
- name: '@signalk/server-admin-ui',
38
- signalk: { displayName: 'Admin UI' }
39
- }
40
- ]
41
-
42
- describe('plugin identity', () => {
43
- it('returns correct id and name', () => {
44
- const plugin = pluginFactory(createMockApp())
45
- assert.equal(plugin.id, 'signalk-app-dock')
46
- assert.equal(plugin.name, 'App Dock')
47
- })
48
-
49
- it('has required plugin interface methods', () => {
50
- const plugin = pluginFactory(createMockApp())
51
- assert.equal(typeof plugin.start, 'function')
52
- assert.equal(typeof plugin.stop, 'function')
53
- assert.equal(typeof plugin.registerWithRouter, 'function')
54
- })
55
- })
56
-
57
- describe('schema', () => {
58
- it('defines all expected properties', () => {
59
- const plugin = pluginFactory(createMockApp())
60
- const props = Object.keys(plugin.schema.properties)
61
- assert.ok(props.includes('position'))
62
- assert.ok(props.includes('iframeMode'))
63
- assert.ok(props.includes('iconSize'))
64
- assert.ok(props.includes('magnification'))
65
- assert.ok(props.includes('magnificationScale'))
66
- assert.ok(props.includes('apps'))
67
- assert.ok(!props.includes('triggerCorner'), 'triggerCorner should be removed')
68
- assert.ok(!props.includes('longPressActivation'), 'longPressActivation should be removed')
69
- })
70
-
71
- it('position enum contains all four edges', () => {
72
- const plugin = pluginFactory(createMockApp())
73
- const posEnum = plugin.schema.properties.position.enum
74
- assert.deepEqual(posEnum, ['bottom', 'top', 'left', 'right'])
75
- })
76
-
77
- it('apps items require url', () => {
78
- const plugin = pluginFactory(createMockApp())
79
- const items = plugin.schema.properties.apps.items
80
- assert.deepEqual(items.required, ['url'])
81
- })
82
-
83
- it('defines showNightModeButton with default true', () => {
84
- const plugin = pluginFactory(createMockApp())
85
- const prop = plugin.schema.properties.showNightModeButton
86
- assert.equal(prop.type, 'boolean')
87
- assert.equal(prop.default, true)
88
- })
89
-
90
- it('defines showExitButton with default false', () => {
91
- const plugin = pluginFactory(createMockApp())
92
- const prop = plugin.schema.properties.showExitButton
93
- assert.equal(prop.type, 'boolean')
94
- assert.equal(prop.default, false)
95
- })
96
-
97
- it('defines tourDismissed with default false', () => {
98
- const plugin = pluginFactory(createMockApp())
99
- const prop = plugin.schema.properties.tourDismissed
100
- assert.equal(prop.type, 'boolean')
101
- assert.equal(prop.default, false)
102
- })
103
- })
104
-
105
- describe('webapp discovery', () => {
106
- it('filters out @signalk/app-dock and admin-ui from /webapps', (t, done) => {
107
- const app = createMockApp(MOCK_WEBAPPS)
108
- const plugin = pluginFactory(app)
109
- const routes = {}
110
- const router = {
111
- get: (path, handler) => {
112
- routes[path] = handler
113
- },
114
- post: () => {}
115
- }
116
- plugin.registerWithRouter(router)
117
-
118
- const res = {
119
- json: (data) => {
120
- assert.equal(data.length, 2)
121
- assert.ok(data.every((w) => w.name !== '@signalk/app-dock'))
122
- assert.ok(data.every((w) => w.name !== '@signalk/server-admin-ui'))
123
- done()
124
- }
125
- }
126
- routes['/webapps']({}, res)
127
- })
128
-
129
- it('maps webapp metadata correctly', (t, done) => {
130
- const app = createMockApp(MOCK_WEBAPPS)
131
- const plugin = pluginFactory(app)
132
- const routes = {}
133
- const router = {
134
- get: (path, handler) => {
135
- routes[path] = handler
136
- },
137
- post: () => {}
138
- }
139
- plugin.registerWithRouter(router)
140
-
141
- const res = {
142
- json: (data) => {
143
- const fb = data.find((w) => w.name === '@signalk/freeboard-sk')
144
- assert.equal(fb.label, 'Freeboard-SK')
145
- assert.equal(fb.url, '/@signalk/freeboard-sk/')
146
- assert.equal(fb.icon, '/@signalk/freeboard-sk/assets/icons/icon-72x72.png')
147
- done()
148
- }
149
- }
150
- routes['/webapps']({}, res)
151
- })
152
-
153
- it('handles missing signalk metadata gracefully', (t, done) => {
154
- const app = createMockApp([{ name: 'bare-plugin' }])
155
- const plugin = pluginFactory(app)
156
- const routes = {}
157
- const router = {
158
- get: (path, handler) => {
159
- routes[path] = handler
160
- },
161
- post: () => {}
162
- }
163
- plugin.registerWithRouter(router)
164
-
165
- const res = {
166
- json: (data) => {
167
- assert.equal(data.length, 1)
168
- assert.equal(data[0].label, 'bare-plugin')
169
- assert.equal(data[0].icon, null)
170
- done()
171
- }
172
- }
173
- routes['/webapps']({}, res)
174
- })
175
-
176
- it('returns empty array when app.webapps is undefined', (t, done) => {
177
- const app = createMockApp()
178
- delete app.webapps
179
- const plugin = pluginFactory(app)
180
- const routes = {}
181
- const router = {
182
- get: (path, handler) => {
183
- routes[path] = handler
184
- },
185
- post: () => {}
186
- }
187
- plugin.registerWithRouter(router)
188
-
189
- const res = {
190
- json: (data) => {
191
- assert.deepEqual(data, [])
192
- done()
193
- }
194
- }
195
- routes['/webapps']({}, res)
196
- })
197
- })
198
-
199
- describe('settings endpoint', () => {
200
- it('returns plugin settings with resolved apps', (t, done) => {
201
- const app = createMockApp()
202
- const plugin = pluginFactory(app)
203
- plugin.start({ position: 'left', iconSize: 64, apps: [] })
204
-
205
- const routes = {}
206
- const router = {
207
- get: (path, handler) => {
208
- routes[path] = handler
209
- },
210
- post: () => {}
211
- }
212
- plugin.registerWithRouter(router)
213
-
214
- const res = {
215
- json: (data) => {
216
- assert.equal(data.position, 'left')
217
- assert.equal(data.iconSize, 64)
218
- assert.ok(Array.isArray(data.apps))
219
- done()
220
- }
221
- }
222
- routes['/settings']({}, res)
223
- })
224
-
225
- it('serves the saved configuration before the plugin has started', (t, done) => {
226
- const app = createMockApp()
227
- app.readPluginOptions = () => ({
228
- enabled: false,
229
- configuration: {
230
- position: 'right',
231
- apps: [
232
- { url: '/a/', label: 'A', enabled: true },
233
- { url: '/b/', label: 'B', enabled: false }
234
- ]
235
- }
236
- })
237
- const plugin = pluginFactory(app)
238
-
239
- const routes = {}
240
- plugin.registerWithRouter({
241
- get: (path, handler) => {
242
- routes[path] = handler
243
- },
244
- post: () => {}
245
- })
246
-
247
- const res = {
248
- json: (data) => {
249
- assert.equal(data.position, 'right')
250
- assert.deepEqual(data.apps, [{ label: 'A', url: '/a/', icon: null, color: null, autostart: false }])
251
- done()
252
- }
253
- }
254
- routes['/settings']({}, res)
255
- })
256
-
257
- it('serves no apps when the saved apps value is not an array', (t, done) => {
258
- const app = createMockApp()
259
- app.readPluginOptions = () => ({ enabled: false, configuration: { apps: {} } })
260
- const plugin = pluginFactory(app)
261
-
262
- const routes = {}
263
- plugin.registerWithRouter({
264
- get: (path, handler) => {
265
- routes[path] = handler
266
- },
267
- post: () => {}
268
- })
269
-
270
- const res = {
271
- json: (data) => {
272
- assert.deepEqual(data.apps, [])
273
- done()
274
- }
275
- }
276
- routes['/settings']({}, res)
277
- })
278
-
279
- it('serves the saved configuration after stop()', (t, done) => {
280
- const app = createMockApp()
281
- app.readPluginOptions = () => ({ enabled: false, configuration: { position: 'top', apps: [] } })
282
- const plugin = pluginFactory(app)
283
- plugin.start({ position: 'left', apps: [{ url: '/a/', label: 'A' }] })
284
- plugin.stop()
285
-
286
- const routes = {}
287
- plugin.registerWithRouter({
288
- get: (path, handler) => {
289
- routes[path] = handler
290
- },
291
- post: () => {}
292
- })
293
-
294
- const res = {
295
- json: (data) => {
296
- assert.equal(data.position, 'top')
297
- assert.deepEqual(data.apps, [])
298
- done()
299
- }
300
- }
301
- routes['/settings']({}, res)
302
- })
303
- })
304
-
305
- describe('default apps seeding', () => {
306
- it('seeds default apps when config has no apps key', () => {
307
- const app = createMockApp()
308
- let savedOptions = null
309
- app.savePluginOptions = (opts, cb) => {
310
- savedOptions = opts
311
- cb(null)
312
- }
313
-
314
- const plugin = pluginFactory(app)
315
- plugin.start({})
316
-
317
- assert.ok(savedOptions)
318
- assert.equal(savedOptions.apps[0].url, '/@signalk/freeboard-sk/')
319
- assert.equal(savedOptions.apps[0].autostart, true)
320
- assert.equal(savedOptions.apps[1].url, '/@halos-org/skip/')
321
- assert.equal(savedOptions.apps[1].autostart, false)
322
- assert.equal(savedOptions.apps[2].url, '/admin/')
323
- assert.equal(savedOptions.apps[2].label, 'Settings')
324
- })
325
-
326
- it('does not seed when apps array already exists (even if empty)', () => {
327
- const app = createMockApp()
328
- let saveCalled = false
329
- app.savePluginOptions = () => {
330
- saveCalled = true
331
- }
332
-
333
- const plugin = pluginFactory(app)
334
- plugin.start({ apps: [] })
335
-
336
- assert.equal(saveCalled, false)
337
- })
338
-
339
- it('preserves existing configured apps', () => {
340
- const app = createMockApp()
341
- let saveCalled = false
342
- app.savePluginOptions = () => {
343
- saveCalled = true
344
- }
345
-
346
- const plugin = pluginFactory(app)
347
- const existing = [{ enabled: true, url: '/custom-app/', label: 'Custom', icon: '', color: '' }]
348
- plugin.start({ apps: existing })
349
-
350
- assert.equal(saveCalled, false)
351
- })
352
-
353
- it('respects enabled:false when resolving apps', () => {
354
- const app = createMockApp()
355
- app.savePluginOptions = (_, cb) => cb(null)
356
- const plugin = pluginFactory(app)
357
-
358
- const configured = [
359
- { enabled: false, url: '/@signalk/freeboard-sk/', label: '', icon: '', color: '' },
360
- { enabled: true, url: '/@mxtommy/kip/', label: '', icon: '', color: '' }
361
- ]
362
- plugin.start({ apps: configured })
363
-
364
- const routes = {}
365
- const router = {
366
- get: (path, handler) => {
367
- routes[path] = handler
368
- },
369
- post: () => {}
370
- }
371
- plugin.registerWithRouter(router)
372
-
373
- let resolved
374
- routes['/settings'](
375
- {},
376
- {
377
- json: (d) => {
378
- resolved = d
379
- }
380
- }
381
- )
382
- assert.ok(!resolved.apps.some((a) => a.url === '/@signalk/freeboard-sk/'))
383
- assert.ok(resolved.apps.some((a) => a.url === '/@mxtommy/kip/'))
384
- })
385
- })
386
-
387
- describe('package.json', () => {
388
- const pkg = JSON.parse(fs.readFileSync(path.join(__dirname, '..', 'package.json'), 'utf-8'))
389
-
390
- it('has signalk-node-server-plugin keyword', () => {
391
- assert.ok(pkg.keywords.includes('signalk-node-server-plugin'))
392
- })
393
-
394
- it('has signalk-webapp keyword', () => {
395
- assert.ok(pkg.keywords.includes('signalk-webapp'))
396
- })
397
-
398
- it('has signalk-plugin-configurator keyword', () => {
399
- assert.ok(pkg.keywords.includes('signalk-plugin-configurator'))
400
- })
401
-
402
- it('main points to plugin/index.js', () => {
403
- assert.equal(pkg.main, 'plugin/index.js')
404
- assert.ok(fs.existsSync(path.join(__dirname, '..', pkg.main)))
405
- })
406
- })
407
-
408
- describe('night mode PUT handler', () => {
409
- it('registers PUT handler when showNightModeButton is true', () => {
410
- let registered = false
411
- const app = createMockApp()
412
- app.registerPutHandler = (context, path, callback, sourceId) => {
413
- registered = true
414
- assert.equal(context, 'vessels.self')
415
- assert.equal(path, 'environment.mode')
416
- assert.equal(sourceId, 'signalk-app-dock')
417
- }
418
-
419
- const plugin = pluginFactory(app)
420
- const originalSetTimeout = globalThis.setTimeout
421
- globalThis.setTimeout = (fn) => fn()
422
- try {
423
- plugin.start({ showNightModeButton: true, apps: [] })
424
- } finally {
425
- globalThis.setTimeout = originalSetTimeout
426
- }
427
- assert.ok(registered)
428
- })
429
-
430
- it('does not register PUT handler when showNightModeButton is false', () => {
431
- let registered = false
432
- const app = createMockApp()
433
- app.registerPutHandler = () => {
434
- registered = true
435
- }
436
-
437
- const plugin = pluginFactory(app)
438
- const originalSetTimeout = globalThis.setTimeout
439
- globalThis.setTimeout = (fn) => fn()
440
- try {
441
- plugin.start({ showNightModeButton: false, apps: [] })
442
- } finally {
443
- globalThis.setTimeout = originalSetTimeout
444
- }
445
- assert.equal(registered, false)
446
- })
447
-
448
- it('PUT handler accepts valid mode values and emits delta', () => {
449
- const app = createMockApp()
450
- let handlerCallback
451
- let emittedDelta = null
452
- app.registerPutHandler = (ctx, path, cb) => {
453
- handlerCallback = cb
454
- }
455
- app.handleMessage = (id, delta) => {
456
- emittedDelta = delta
457
- }
458
-
459
- const plugin = pluginFactory(app)
460
- const originalSetTimeout = globalThis.setTimeout
461
- globalThis.setTimeout = (fn) => fn()
462
- try {
463
- plugin.start({ showNightModeButton: true, apps: [] })
464
- } finally {
465
- globalThis.setTimeout = originalSetTimeout
466
- }
467
-
468
- const result = handlerCallback('vessels.self', 'environment.mode', 'night', () => {})
469
- assert.equal(result.state, 'COMPLETED')
470
- assert.equal(result.statusCode, 200)
471
- assert.equal(emittedDelta.updates[0].values[0].path, 'environment.mode')
472
- assert.equal(emittedDelta.updates[0].values[0].value, 'night')
473
- })
474
-
475
- it('PUT handler rejects invalid mode values', () => {
476
- const app = createMockApp()
477
- let handlerCallback
478
- app.registerPutHandler = (ctx, path, cb) => {
479
- handlerCallback = cb
480
- }
481
-
482
- const plugin = pluginFactory(app)
483
- const originalSetTimeout = globalThis.setTimeout
484
- globalThis.setTimeout = (fn) => fn()
485
- try {
486
- plugin.start({ showNightModeButton: true, apps: [] })
487
- } finally {
488
- globalThis.setTimeout = originalSetTimeout
489
- }
490
-
491
- const result = handlerCallback('vessels.self', 'environment.mode', 'invalid', () => {})
492
- assert.equal(result.statusCode, 400)
493
- })
494
- })
495
-
496
- describe('dismiss-tour endpoint', () => {
497
- it('saves tourDismissed: true and responds', (t, done) => {
498
- const app = createMockApp()
499
- let saved = null
500
- app.savePluginOptions = (opts, cb) => {
501
- saved = opts
502
- cb(null)
503
- }
504
- const plugin = pluginFactory(app)
505
- plugin.start({ apps: [], position: 'left' })
506
-
507
- const routes = { get: {}, post: {} }
508
- const router = {
509
- get: (path, handler) => {
510
- routes.get[path] = handler
511
- },
512
- post: (path, handler) => {
513
- routes.post[path] = handler
514
- }
515
- }
516
- plugin.registerWithRouter(router)
517
-
518
- const res = {
519
- json: (data) => {
520
- assert.equal(data.tourDismissed, true)
521
- assert.equal(saved.tourDismissed, true)
522
- assert.equal(saved.position, 'left')
523
- done()
524
- }
525
- }
526
- routes.post['/dismiss-tour']({}, res)
527
- })
528
-
529
- it('keeps the saved configuration when the plugin is not running', (t, done) => {
530
- const app = createMockApp()
531
- const apps = [{ url: '/a/', label: 'A', enabled: true }]
532
- app.readPluginOptions = () => ({ enabled: false, configuration: { position: 'right', apps } })
533
- let saved = null
534
- app.savePluginOptions = (opts, cb) => {
535
- saved = opts
536
- cb(null)
537
- }
538
- const plugin = pluginFactory(app)
539
-
540
- const routes = {}
541
- plugin.registerWithRouter({
542
- get: () => {},
543
- post: (path, handler) => {
544
- routes[path] = handler
545
- }
546
- })
547
-
548
- const res = {
549
- json: (data) => {
550
- assert.equal(data.tourDismissed, true)
551
- assert.deepEqual(saved, { position: 'right', apps, tourDismissed: true })
552
- done()
553
- }
554
- }
555
- routes['/dismiss-tour']({}, res)
556
- })
557
- })
558
-
559
- describe('mode endpoint', () => {
560
- it('returns day when no mode is set', (t, done) => {
561
- const app = createMockApp()
562
- const plugin = pluginFactory(app)
563
- const routes = {}
564
- const router = {
565
- get: (path, handler) => {
566
- routes[path] = handler
567
- },
568
- post: () => {}
569
- }
570
- plugin.registerWithRouter(router)
571
-
572
- const res = {
573
- json: (data) => {
574
- assert.equal(data.value, 'day')
575
- done()
576
- }
577
- }
578
- routes['/mode']({}, res)
579
- })
580
-
581
- it('returns current mode when set', (t, done) => {
582
- const app = createMockApp()
583
- app.getSelfPath = (path) => {
584
- if (path === 'environment.mode') return { value: 'night' }
585
- return null
586
- }
587
- const plugin = pluginFactory(app)
588
- const routes = {}
589
- const router = {
590
- get: (path, handler) => {
591
- routes[path] = handler
592
- },
593
- post: () => {}
594
- }
595
- plugin.registerWithRouter(router)
596
-
597
- const res = {
598
- json: (data) => {
599
- assert.equal(data.value, 'night')
600
- done()
601
- }
602
- }
603
- routes['/mode']({}, res)
604
- })
605
- })
606
-
607
- describe('route access', () => {
608
- // Records each route with the level it is registered at: 'admin' for a
609
- // route registered on the router itself, the access() level otherwise.
610
- function recordingRouter({ withAccess }) {
611
- const levels = {}
612
- const registrar = (level) => ({
613
- get: (path) => {
614
- levels[`GET ${path}`] = level
615
- },
616
- post: (path) => {
617
- levels[`POST ${path}`] = level
618
- }
619
- })
620
- const router = registrar('admin')
621
- if (withAccess) router.access = registrar
622
- return { router, levels }
623
- }
624
-
625
- it('opens /settings and /mode to readonly users and /dismiss-tour to readwrite users', () => {
626
- const plugin = pluginFactory(createMockApp())
627
- const { router, levels } = recordingRouter({ withAccess: true })
628
- plugin.registerWithRouter(router)
629
-
630
- assert.deepEqual(levels, {
631
- 'GET /settings': 'readonly',
632
- 'GET /webapps': 'admin',
633
- 'GET /mode': 'readonly',
634
- 'POST /dismiss-tour': 'readwrite'
635
- })
636
- })
637
-
638
- it('registers every route on the router itself when it has no access()', () => {
639
- const plugin = pluginFactory(createMockApp())
640
- const { router, levels } = recordingRouter({ withAccess: false })
641
- plugin.registerWithRouter(router)
642
-
643
- assert.deepEqual(levels, {
644
- 'GET /settings': 'admin',
645
- 'GET /webapps': 'admin',
646
- 'GET /mode': 'admin',
647
- 'POST /dismiss-tour': 'admin'
648
- })
649
- })
650
- })
651
-
652
- describe('public files', () => {
653
- const publicDir = path.join(__dirname, '..', 'public')
654
-
655
- it('index.html exists', () => {
656
- assert.ok(fs.existsSync(path.join(publicDir, 'index.html')))
657
- })
658
-
659
- it('dock.js exists', () => {
660
- assert.ok(fs.existsSync(path.join(publicDir, 'dock.js')))
661
- })
662
-
663
- it('dock.css exists', () => {
664
- assert.ok(fs.existsSync(path.join(publicDir, 'dock.css')))
665
- })
666
-
667
- it('remoteEntry.js exists (Module Federation)', () => {
668
- assert.ok(fs.existsSync(path.join(publicDir, 'remoteEntry.js')))
669
- })
670
-
671
- it('app-icon.svg exists', () => {
672
- assert.ok(fs.existsSync(path.join(publicDir, 'app-icon.svg')))
673
- })
674
-
675
- it('icon-settings.svg exists', () => {
676
- assert.ok(fs.existsSync(path.join(publicDir, 'icon-settings.svg')))
677
- })
678
-
679
- it('manifest.webmanifest exists and is valid JSON with required PWA fields', () => {
680
- const manifestPath = path.join(publicDir, 'manifest.webmanifest')
681
- assert.ok(fs.existsSync(manifestPath))
682
- const manifest = JSON.parse(fs.readFileSync(manifestPath, 'utf8'))
683
- assert.equal(typeof manifest.name, 'string')
684
- assert.equal(typeof manifest.start_url, 'string')
685
- assert.equal(manifest.display, 'standalone')
686
- assert.ok(Array.isArray(manifest.icons) && manifest.icons.length > 0)
687
- })
688
-
689
- it('app-icon-180.png exists (apple-touch-icon)', () => {
690
- assert.ok(fs.existsSync(path.join(publicDir, 'app-icon-180.png')))
691
- })
692
-
693
- it('app-icon-maskable.svg exists', () => {
694
- assert.ok(fs.existsSync(path.join(publicDir, 'app-icon-maskable.svg')))
695
- })
696
- })