Files
meshcore-analyzer/test-nodes-export-wiring.js
T
efitenandClaude Opus 5 aabbd50d27 feat(nodes): export the visible node list as MeshCore companion contacts JSON (#1889)
## What

Adds an **Export JSON** button to the Nodes topbar that downloads the
currently visible node list as a MeshCore companion-app config file:

```json
{
  "contacts": [
    {
      "type": 2,
      "name": "BE-MGU-RP03 | ON7YT",
      "custom_name": null,
      "public_key": "7a7a37d4819fb27440ed1439ca7d281fdecceb83b27e61a69745feb004d726a4",
      "flags": 0,
      "latitude": "51.07307",
      "longitude": "5.5796",
      "last_advert": 1786545431,
      "last_modified": 1786545431,
      "out_path_list": null
    }
  ]
}
```

That is the exact shape the companion app itself writes, so the file
imports straight back into a companion app as contacts. The practical
use case is per-area: pick an area in the area filter, export, and hand
someone the repeaters for that region instead of having them wait for
adverts.

## Scope of the export

WYSIWYG — the button exports the rows the table is showing, in the
table's current order, so area, region, role tab, search, last-heard and
status filters all carry over. The button label shows how many contacts
the file will contain and is disabled when that count is zero.

## Field mapping

| JSON field | Source | Notes |
|---|---|---|
| `type` | `node.role` | repeater→2, companion→1, room→3, sensor→4;
unknown/empty→1 |
| `name` | `node.name` | unchanged, emoji included |
| `custom_name` | — | always `null` |
| `public_key` | `node.public_key` | full 64-hex |
| `flags` | — | always `0` |
| `latitude` / `longitude` | `node.lat` / `node.lon` | stringified, as
the format expects |
| `last_advert` | `node.last_seen` | RFC3339 → unix seconds |
| `last_modified` | mirrors `last_advert` | no separate source exists |
| `out_path_list` | — | always `null`; the companion app discovers
routes itself |

Nodes are skipped when they have no name, a pubkey shorter than 64 hex
chars, or no usable position (missing, non-numeric, or null island).

Filename: `corescope_nodes_<area|all>_YYYY-MM-DD-HHMMSS.json`.

## Shape of the change

The mapping lives in a self-contained `public/nodes-export.js`
(`window.NodesExport.buildContacts/filename/download`); `nodes.js` only
gains the button markup, a click handler and a small
`updateExportBtn()`. No backend change, no new API call — the export
reuses the already-fetched node list, so there is nothing per-node to
fetch.

## Tests

- `test-nodes-export.js` — field mapping and key order, role→type table,
skip rules, order preservation, filename format (added to
`test-all.sh`).
- `test-nodes-export-wiring.js` — `index.html` loads the module before
`nodes.js`; the button lives in the topbar and hands the filtered
`nodes` array plus `AreaFilter.getSelected()` to
`NodesExport.download()` (added to `test-all.sh`).
- `test-nodes-export-e2e.js` — Playwright: downloads the file, validates
the JSON shape per contact, asserts the button count matches the file,
and that narrowing the search shrinks the export set.

## Browser validation

Deployed to staging and checked in Chromium:

- Desktop 1400×900 — button renders at the right of the topbar next to
the count pills, `Export JSON (1962)`.
- Mobile 390×844 — topbar stacks, button visible, no horizontal overflow
(`scrollWidth == clientWidth`).
- E2E against staging: 1761 contacts exported,
`corescope_nodes_all_2026-08-12-164349.json`, shape validated, search
narrowing confirmed.
- With the `BE-LIM` area filter active: 44 contacts,
`corescope_nodes_BE-LIM_2026-08-12-164445.json`, type histogram `{1: 1,
2: 42, 3: 1}`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-02 14:22:51 +02:00

57 lines
2.5 KiB
JavaScript

/**
* Wiring tests for the Nodes JSON export: index.html loads nodes-export.js,
* and nodes.js renders an Export JSON button in the topbar that hands the
* currently filtered node list + active area key to NodesExport.download().
*
* Pure source-string assertions (no browser); behavior lives in
* test-nodes-export.js, DOM behavior in test-nodes-export-e2e.js.
*/
'use strict';
const fs = require('fs');
const path = require('path');
let passed = 0;
let failed = 0;
function assert(cond, msg) {
if (cond) { passed++; console.log(' ✓ ' + msg); }
else { failed++; console.error(' ✗ ' + msg); }
}
const html = fs.readFileSync(path.join(__dirname, 'public/index.html'), 'utf8');
const src = fs.readFileSync(path.join(__dirname, 'public/nodes.js'), 'utf8');
console.log('\n=== nodes-export.js is loaded by the SPA shell ===');
assert(/<script src="nodes-export\.js\?v=__BUST__"/.test(html),
'index.html loads nodes-export.js with the __BUST__ cache buster');
const exportTagIdx = html.indexOf('src="nodes-export.js');
const nodesTagIdx = html.indexOf('src="nodes.js');
assert(exportTagIdx > 0 && nodesTagIdx > 0 && exportTagIdx < nodesTagIdx,
'nodes-export.js is loaded before nodes.js');
console.log('\n=== Nodes topbar renders the export button ===');
assert(/id="nodesExportBtn"/.test(src), 'nodes.js renders an element with id nodesExportBtn');
const topbarIdx = src.indexOf('nodes-topbar');
const topbarBlock = src.substring(topbarIdx, src.indexOf('</div>', src.indexOf('nodesAreaFilter')));
assert(topbarIdx > 0 && /nodesExportBtn/.test(topbarBlock),
'the export button sits in the nodes topbar');
console.log('\n=== Click handler exports the visible list for the active area ===');
const handlerIdx = src.indexOf("getElementById('nodesExportBtn')");
assert(handlerIdx > 0, 'found the nodesExportBtn handler block');
const handlerBlock = src.substring(handlerIdx, handlerIdx + 600);
assert(/NodesExport\.download\s*\(/.test(handlerBlock),
'handler calls NodesExport.download(...)');
assert(/NodesExport\.download\(\s*nodes\s*,/.test(handlerBlock),
'handler passes the filtered `nodes` array (WYSIWYG), not _allNodes');
assert(/AreaFilter\.getSelected\(\)/.test(handlerBlock),
'handler passes the active area key from AreaFilter.getSelected()');
console.log('\n=== Empty list disables the button ===');
assert(/nodesExportBtn[\s\S]{0,400}?\.disabled\s*=/.test(src) ||
/exportBtn\.disabled\s*=/.test(src),
'the export button is disabled when there is nothing to export');
console.log('\n' + passed + ' passed, ' + failed + ' failed');
process.exit(failed ? 1 : 0);