frugal-iot-client 2.0.2 → 2.0.4
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/CLAUDE.md +6 -1
- package/admin.js +133 -20
- package/cards.js +20 -0
- package/core.js +166 -8
- package/docs/FLASH_PLAN.md +30 -0
- package/flash.js +128 -37
- package/frugaliot.css +2 -0
- package/package.json +1 -1
- package/test/flash.test.js +220 -0
- package/test/graph.test.js +21 -0
- package/test/page.test.js +121 -3
- package/test/setup.js +4 -3
package/test/page.test.js
CHANGED
|
@@ -84,7 +84,7 @@ describe('which admin cards a user gets', () => {
|
|
|
84
84
|
assert.deepEqual(cards.adminCardsFor('dev').map((c) => c.section), ['info', 'flash']);
|
|
85
85
|
withCapabilities('READ', 'ADMIN');
|
|
86
86
|
assert.deepEqual(cards.adminCardsFor('dev').map((c) => c.section),
|
|
87
|
-
['info', 'admin', 'projects', 'message', 'retained', 'nodes', 'api']);
|
|
87
|
+
['info', 'admin', 'projects', 'message', 'retained', 'nodes', 'bridges', 'api']);
|
|
88
88
|
});
|
|
89
89
|
|
|
90
90
|
test('flashing and pushing an OTA binary are separate capabilities', () => {
|
|
@@ -108,7 +108,7 @@ describe('the project back', () => {
|
|
|
108
108
|
const back = document.createElement('mqtt-projectback');
|
|
109
109
|
back.setAttribute('organization', 'dev');
|
|
110
110
|
document.body.append(back);
|
|
111
|
-
assert.equal(back.querySelectorAll('.fi-admincard').length,
|
|
111
|
+
assert.equal(back.querySelectorAll('.fi-admincard').length, 8);
|
|
112
112
|
back.remove();
|
|
113
113
|
});
|
|
114
114
|
|
|
@@ -228,11 +228,129 @@ describe('the project back', () => {
|
|
|
228
228
|
document.body.append(back);
|
|
229
229
|
back.querySelectorAll('.fi-admincard__head').forEach((h) => h.click());
|
|
230
230
|
const sections = [...back.querySelectorAll('mqtt-admin')].map((a) => a.getAttribute('section'));
|
|
231
|
-
assert.deepEqual(sections, ['admin', 'projects', 'message', 'retained', 'nodes', 'api']);
|
|
231
|
+
assert.deepEqual(sections, ['admin', 'projects', 'message', 'retained', 'nodes', 'bridges', 'api']);
|
|
232
232
|
back.remove();
|
|
233
233
|
});
|
|
234
234
|
});
|
|
235
235
|
|
|
236
|
+
// The bolt is in the OTA card and the flasher is a card of its own, so this is the one path between
|
|
237
|
+
// two mqtt-admin elements that cannot be reached by either on its own.
|
|
238
|
+
describe('the bolt beside an OTA file', () => {
|
|
239
|
+
// /ota_get is a real request the jsdom tests cannot make; record it instead
|
|
240
|
+
function withFetchRecorded(fn) {
|
|
241
|
+
const asked = [];
|
|
242
|
+
const real = globalThis.fetch;
|
|
243
|
+
globalThis.fetch = (url) => { asked.push(url); return Promise.resolve({ok: true, arrayBuffer: () => Promise.resolve(new ArrayBuffer(4))}); };
|
|
244
|
+
try { fn(asked); } finally { globalThis.fetch = real; }
|
|
245
|
+
}
|
|
246
|
+
function backWithOtaFile(file) {
|
|
247
|
+
const back = document.createElement('mqtt-projectback');
|
|
248
|
+
back.setAttribute('organization', 'dev');
|
|
249
|
+
document.body.append(back);
|
|
250
|
+
back.state.elements.ota.querySelector('.fi-admincard__head').click();
|
|
251
|
+
const otaAdmin = back.state.elements.ota.querySelector('mqtt-admin');
|
|
252
|
+
otaAdmin.state.ota_files = [file]; // what /ota_list would have said
|
|
253
|
+
otaAdmin.replaceElement('ota_files', otaAdmin.otaFilesList());
|
|
254
|
+
return {back, otaAdmin};
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
test('opens the Flash card and hands it the file', () => {
|
|
258
|
+
withCapabilities('OTAUPDATE', 'OTAFLASH');
|
|
259
|
+
const {back, otaAdmin} = backWithOtaFile('esp32/lotus/1.0.0');
|
|
260
|
+
withFetchRecorded((asked) => {
|
|
261
|
+
otaAdmin.shadowRoot.querySelector('.otaicon').click(); // the bolt, not a handler call
|
|
262
|
+
assert.ok(back.state.elements.flash.classList.contains('fi-admincard--open'), 'Flash stayed shut');
|
|
263
|
+
assert.deepEqual(asked, ['/ota_get/dev/esp32/lotus/1.0.0']);
|
|
264
|
+
});
|
|
265
|
+
back.remove();
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
test('is not offered without OTAFLASH, since there is then no Flash card to open', () => {
|
|
269
|
+
withCapabilities('OTAUPDATE');
|
|
270
|
+
const {back, otaAdmin} = backWithOtaFile('esp32/lotus/1.0.0');
|
|
271
|
+
assert.equal(otaAdmin.shadowRoot.querySelector('.otaicon[title]').textContent, '\u{1f5d1}',
|
|
272
|
+
'the only icon should be the basket');
|
|
273
|
+
back.remove();
|
|
274
|
+
});
|
|
275
|
+
|
|
276
|
+
test('the name downloads rather than deleting - a mis-tap cost a file', () => {
|
|
277
|
+
withCapabilities('OTAUPDATE');
|
|
278
|
+
const {back, otaAdmin} = backWithOtaFile('esp32/lotus/1.0.0');
|
|
279
|
+
const link = otaAdmin.shadowRoot.querySelector('a[download]');
|
|
280
|
+
assert.ok(link, 'the file name should be a download link');
|
|
281
|
+
assert.equal(link.textContent, 'esp32/lotus/1.0.0');
|
|
282
|
+
assert.equal(link.getAttribute('href'), '/ota_get/dev/esp32/lotus/1.0.0');
|
|
283
|
+
back.remove();
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
// The server answers an OTA upload with a redirect, so a plain form post landed the whole page on
|
|
288
|
+
// /dashboard/ - no organization chosen, and the admin cards shut.
|
|
289
|
+
describe('uploading an OTA binary', () => {
|
|
290
|
+
// Every request the card makes: the list it reads on opening, the upload, and the re-read after.
|
|
291
|
+
// Node's Request wants an absolute URL, where a browser resolves one against the page, so GET()
|
|
292
|
+
// cannot reach the stub without standing in for that too.
|
|
293
|
+
function withFetch(fn) {
|
|
294
|
+
const asked = [];
|
|
295
|
+
const real = {fetch: globalThis.fetch, Request: globalThis.Request};
|
|
296
|
+
globalThis.Request = class { constructor(url, init) { Object.assign(this, init); this.url = String(url); } };
|
|
297
|
+
globalThis.fetch = (req, opts) => {
|
|
298
|
+
const url = (req && req.url) || String(req);
|
|
299
|
+
asked.push({url, method: (opts && opts.method) || (req && req.method) || 'GET'});
|
|
300
|
+
if (url.startsWith('/ota_list')) return Promise.resolve({
|
|
301
|
+
ok: true, status: 200, url,
|
|
302
|
+
headers: {get: () => 'application/json'},
|
|
303
|
+
json: () => Promise.resolve(['esp32/lotus/1.0.0']),
|
|
304
|
+
});
|
|
305
|
+
return Promise.resolve({ok: true, status: 200, body: null, // what the redirect lands on
|
|
306
|
+
url: '/dashboard/?message=OTA%20binary%20uploaded&lang=EN'});
|
|
307
|
+
};
|
|
308
|
+
return Promise.resolve(fn(asked))
|
|
309
|
+
.finally(() => { globalThis.fetch = real.fetch; globalThis.Request = real.Request; });
|
|
310
|
+
}
|
|
311
|
+
// Opening the card reads the OTA list, so the stub has to be in place before it is built
|
|
312
|
+
function otaCardAdmin() {
|
|
313
|
+
const back = document.createElement('mqtt-projectback');
|
|
314
|
+
back.setAttribute('organization', 'dev');
|
|
315
|
+
document.body.append(back);
|
|
316
|
+
back.state.elements.ota.querySelector('.fi-admincard__head').click();
|
|
317
|
+
return {back, otaAdmin: back.state.elements.ota.querySelector('mqtt-admin')};
|
|
318
|
+
}
|
|
319
|
+
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));
|
|
320
|
+
|
|
321
|
+
test('stays on the card it was posted from', async () => {
|
|
322
|
+
withCapabilities('OTAUPDATE');
|
|
323
|
+
await withFetch(async (asked) => {
|
|
324
|
+
const {back, otaAdmin} = otaCardAdmin();
|
|
325
|
+
const form = otaAdmin.shadowRoot.querySelector('form');
|
|
326
|
+
const notPrevented = form.dispatchEvent(new Event('submit', {cancelable: true, bubbles: true}));
|
|
327
|
+
assert.equal(notPrevented, false, 'the browser would have navigated away');
|
|
328
|
+
await settle();
|
|
329
|
+
const upload = asked.find((a) => a.url === '/ota_update');
|
|
330
|
+
assert.ok(upload, 'nothing was posted');
|
|
331
|
+
assert.equal(upload.method, 'POST');
|
|
332
|
+
assert.ok(back.querySelector('mqtt-admin'), 'the card should still be here');
|
|
333
|
+
back.remove();
|
|
334
|
+
});
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
test('says what the server said, and re-reads the list', async () => {
|
|
338
|
+
withCapabilities('OTAUPDATE');
|
|
339
|
+
await withFetch(async (asked) => {
|
|
340
|
+
const {back, otaAdmin} = otaCardAdmin();
|
|
341
|
+
await settle(); // the list the card reads on opening
|
|
342
|
+
const before = asked.filter((a) => a.url === '/ota_list/dev').length;
|
|
343
|
+
otaAdmin.shadowRoot.querySelector('form')
|
|
344
|
+
.dispatchEvent(new Event('submit', {cancelable: true, bubbles: true}));
|
|
345
|
+
await settle();
|
|
346
|
+
assert.equal(otaAdmin.state.elements.message.textContent, 'OTA binary uploaded');
|
|
347
|
+
assert.equal(asked.filter((a) => a.url === '/ota_list/dev').length, before + 1,
|
|
348
|
+
'the list should have been re-read, which is what actually shows the upload arrived');
|
|
349
|
+
back.remove();
|
|
350
|
+
});
|
|
351
|
+
});
|
|
352
|
+
});
|
|
353
|
+
|
|
236
354
|
describe('the page', () => {
|
|
237
355
|
test('with no project chosen it says so rather than showing an empty grid', () => {
|
|
238
356
|
const page = document.createElement('mqtt-dashboard');
|
package/test/setup.js
CHANGED
|
@@ -25,9 +25,10 @@ for (const k of Object.getOwnPropertyNames(dom.window)) {
|
|
|
25
25
|
}
|
|
26
26
|
globalThis.window = dom.window;
|
|
27
27
|
globalThis.document = dom.window.document;
|
|
28
|
-
// Node provides its own Event/CustomEvent/EventTarget globals, which jsdom's document rejects
|
|
29
|
-
//
|
|
30
|
-
|
|
28
|
+
// Node provides its own Event/CustomEvent/EventTarget globals, which jsdom's document rejects, and
|
|
29
|
+
// its own FormData, which refuses to be built from a jsdom <form>. The loop above skips them
|
|
30
|
+
// because they already exist, so override explicitly.
|
|
31
|
+
for (const k of ['Event', 'CustomEvent', 'EventTarget', 'MessageEvent', 'FormData']) {
|
|
31
32
|
globalThis[k] = dom.window[k];
|
|
32
33
|
}
|
|
33
34
|
|