@meri-imperiumi/signalk-triplogger 1.3.2 → 1.4.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.
- package/CHANGELOG.md +25 -0
- package/Logger.js +61 -14
- package/index.js +118 -30
- package/package.json +1 -1
- package/tests/logger.test.js +148 -0
- package/tests/plugin.test.js +257 -0
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,30 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## [Unreleased]
|
|
4
|
+
|
|
5
|
+
## [1.4.1] - 2026-09-10
|
|
6
|
+
|
|
7
|
+
### Fixed
|
|
8
|
+
- Fix server crash when a log file is corrupt, empty, or not a JSON
|
|
9
|
+
object: parse errors are now caught inside the read callback instead of
|
|
10
|
+
escaping as an uncaught exception that killed signalk-server. The bad
|
|
11
|
+
file is backed up as `<name>.json.corrupt` and a fresh log is started
|
|
12
|
+
- Persist logs atomically (temp file + fsync + rename) so a crash or
|
|
13
|
+
power loss mid-write can no longer truncate log files
|
|
14
|
+
|
|
15
|
+
## [1.4.0] - 2026-09-09
|
|
16
|
+
|
|
17
|
+
### Added
|
|
18
|
+
- Option to publish the current trip log (`log_path`) and the total log
|
|
19
|
+
(`totals_path`) under configurable Signal K paths, for setups where the
|
|
20
|
+
standard `navigation.trip.log`/`navigation.log` paths are owned by another
|
|
21
|
+
provider. Non-standard paths are announced with `units: m` metadata
|
|
22
|
+
|
|
23
|
+
### Fixed
|
|
24
|
+
- Don't overwrite the current trip log at startup: log preparation is now
|
|
25
|
+
serialized so distance appends and state handling always run against the
|
|
26
|
+
persisted log once it has finished loading from disk
|
|
27
|
+
|
|
3
28
|
## [1.3.2] - 2026-08-23
|
|
4
29
|
|
|
5
30
|
### Added
|
package/Logger.js
CHANGED
|
@@ -1,15 +1,24 @@
|
|
|
1
1
|
const fs = require('fs');
|
|
2
|
+
const { promisify } = require('util');
|
|
3
|
+
|
|
4
|
+
const fsWriteFile = promisify(fs.writeFile);
|
|
5
|
+
const fsOpen = promisify(fs.open);
|
|
6
|
+
const fsFsync = promisify(fs.fsync);
|
|
7
|
+
const fsClose = promisify(fs.close);
|
|
8
|
+
const fsRename = promisify(fs.rename);
|
|
2
9
|
|
|
3
10
|
function toNM(meters) {
|
|
4
11
|
return (meters / 1852).toFixed(2);
|
|
5
12
|
}
|
|
6
13
|
|
|
7
14
|
class Logger {
|
|
8
|
-
constructor(filename) {
|
|
15
|
+
constructor(filename, onError) {
|
|
9
16
|
this.state = null;
|
|
10
17
|
this.filename = filename;
|
|
18
|
+
this.onError = onError;
|
|
11
19
|
this.reset();
|
|
12
20
|
this.unsaved = false;
|
|
21
|
+
this.saves = Promise.resolve();
|
|
13
22
|
}
|
|
14
23
|
|
|
15
24
|
reset() {
|
|
@@ -70,18 +79,36 @@ class Logger {
|
|
|
70
79
|
}
|
|
71
80
|
|
|
72
81
|
save() {
|
|
82
|
+
// Serialize writes: concurrent saves writing to the same temp file
|
|
83
|
+
// could interleave, and an older snapshot could be renamed over a
|
|
84
|
+
// newer one. Each save runs only after the previous one settles.
|
|
85
|
+
const previous = this.saves;
|
|
86
|
+
this.saves = previous.then(
|
|
87
|
+
() => this.write(),
|
|
88
|
+
() => this.write(),
|
|
89
|
+
);
|
|
90
|
+
return this.saves;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
async write() {
|
|
73
94
|
if (!this.unsaved) {
|
|
74
|
-
return
|
|
95
|
+
return;
|
|
75
96
|
}
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
}
|
|
97
|
+
const tmpFile = `${this.filename}.tmp`;
|
|
98
|
+
await fsWriteFile(tmpFile, JSON.stringify(this.log, null, 2));
|
|
99
|
+
// Flush the temp file to disk before renaming: without fsync, a
|
|
100
|
+
// power loss can persist the rename while the file's contents never
|
|
101
|
+
// made it to disk, recreating a truncated log
|
|
102
|
+
const fd = await fsOpen(tmpFile, 'r+');
|
|
103
|
+
try {
|
|
104
|
+
await fsFsync(fd);
|
|
105
|
+
} finally {
|
|
106
|
+
await fsClose(fd);
|
|
107
|
+
}
|
|
108
|
+
// Rename over an existing file is atomic: readers see either the
|
|
109
|
+
// old log or the new one, never a partially written one
|
|
110
|
+
await fsRename(tmpFile, this.filename);
|
|
111
|
+
this.unsaved = false;
|
|
85
112
|
}
|
|
86
113
|
|
|
87
114
|
exists() {
|
|
@@ -103,9 +130,29 @@ class Logger {
|
|
|
103
130
|
reject(err);
|
|
104
131
|
return;
|
|
105
132
|
}
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
133
|
+
try {
|
|
134
|
+
const parsed = JSON.parse(contents);
|
|
135
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
136
|
+
throw new Error('Not a JSON object');
|
|
137
|
+
}
|
|
138
|
+
this.log = parsed;
|
|
139
|
+
this.unsaved = false;
|
|
140
|
+
resolve();
|
|
141
|
+
} catch (parseError) {
|
|
142
|
+
// Corrupt (for example truncated by a crash mid-write) or empty
|
|
143
|
+
// log file. Back it up for manual recovery and start a fresh log
|
|
144
|
+
// instead of failing the whole plugin.
|
|
145
|
+
const backup = `${this.filename}.corrupt`;
|
|
146
|
+
fs.rename(this.filename, backup, () => {
|
|
147
|
+
this.reset();
|
|
148
|
+
if (this.onError) {
|
|
149
|
+
this.onError(
|
|
150
|
+
`Corrupt log file ${this.filename} (${parseError.message}). Backed up to ${backup}, starting a fresh log`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
resolve();
|
|
154
|
+
});
|
|
155
|
+
}
|
|
109
156
|
});
|
|
110
157
|
});
|
|
111
158
|
}
|
package/index.js
CHANGED
|
@@ -27,37 +27,59 @@ module.exports = (app) => {
|
|
|
27
27
|
return join(app.getDataDirPath(), `${logName}.json`);
|
|
28
28
|
}
|
|
29
29
|
|
|
30
|
+
let logsPrepared = Promise.resolve();
|
|
31
|
+
|
|
30
32
|
function prepareLogs() {
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
logs[logName] = new Logger(getLogPath(logName));
|
|
47
|
-
return logs[logName].exists()
|
|
48
|
-
.then((exists) => {
|
|
49
|
-
if (!exists) {
|
|
50
|
-
// New log, no need to load
|
|
51
|
-
return Promise.resolve();
|
|
52
|
-
}
|
|
53
|
-
return logs[logName].load();
|
|
54
|
-
});
|
|
33
|
+
// Serialize preparations: a Logger instance is created synchronously,
|
|
34
|
+
// but only loaded from disk asynchronously. Without serialization a
|
|
35
|
+
// second call (for example, a position delta right after a state
|
|
36
|
+
// delta at startup) would see the instance already in `logs` and
|
|
37
|
+
// proceed before `load()` has finished, appending to and saving a
|
|
38
|
+
// fresh zeroed log over the persisted one.
|
|
39
|
+
const preparation = logsPrepared.then(() => {
|
|
40
|
+
const newLogs = getLogNames();
|
|
41
|
+
return Promise.all(Object.keys(logs).map((logName) => {
|
|
42
|
+
// Close old logs
|
|
43
|
+
if (newLogs.indexOf(logName) === -1) {
|
|
44
|
+
// If log is not in the new log list, end it
|
|
45
|
+
const oldLog = logs[logName];
|
|
46
|
+
delete logs[logName];
|
|
47
|
+
return oldLog.endTrip();
|
|
55
48
|
}
|
|
56
49
|
return Promise.resolve();
|
|
57
|
-
}))
|
|
50
|
+
}))
|
|
51
|
+
.then(() => Promise.all(newLogs.map((logName) => {
|
|
52
|
+
// Load new logs
|
|
53
|
+
if (!logs[logName]) {
|
|
54
|
+
// New log, or saved log?
|
|
55
|
+
const log = new Logger(
|
|
56
|
+
getLogPath(logName),
|
|
57
|
+
(message) => app.error(message),
|
|
58
|
+
);
|
|
59
|
+
logs[logName] = log;
|
|
60
|
+
return log.exists()
|
|
61
|
+
.then((exists) => {
|
|
62
|
+
if (!exists) {
|
|
63
|
+
// New log, no need to load
|
|
64
|
+
return Promise.resolve();
|
|
65
|
+
}
|
|
66
|
+
return log.load();
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
return Promise.resolve();
|
|
70
|
+
})));
|
|
71
|
+
});
|
|
72
|
+
// Keep the chain usable even if a single preparation fails
|
|
73
|
+
logsPrepared = preparation.catch(() => {});
|
|
74
|
+
return preparation;
|
|
58
75
|
}
|
|
59
76
|
|
|
60
77
|
plugin.start = (options) => {
|
|
78
|
+
// Paths the logs are published under. Configure these when another
|
|
79
|
+
// provider owns the standard paths, for example when a club's total
|
|
80
|
+
// log is maintained elsewhere for their maintenance schedule.
|
|
81
|
+
const tripLogPath = options.log_path || 'navigation.trip.log';
|
|
82
|
+
const totalLogPath = options.totals_path || 'navigation.log';
|
|
61
83
|
const subscription = {
|
|
62
84
|
context: 'vessels.self',
|
|
63
85
|
subscribe: [
|
|
@@ -77,7 +99,7 @@ module.exports = (app) => {
|
|
|
77
99
|
const resetTime = logs.current.log.started;
|
|
78
100
|
const values = [
|
|
79
101
|
{
|
|
80
|
-
path:
|
|
102
|
+
path: tripLogPath,
|
|
81
103
|
value: logs.current.log.total,
|
|
82
104
|
},
|
|
83
105
|
{
|
|
@@ -88,7 +110,7 @@ module.exports = (app) => {
|
|
|
88
110
|
if (options.totals) {
|
|
89
111
|
const base = options.totals_base || 0;
|
|
90
112
|
values.push({
|
|
91
|
-
path:
|
|
113
|
+
path: totalLogPath,
|
|
92
114
|
value: logs.total.log.total + base,
|
|
93
115
|
});
|
|
94
116
|
}
|
|
@@ -118,14 +140,14 @@ module.exports = (app) => {
|
|
|
118
140
|
.then(() => {
|
|
119
141
|
const values = [
|
|
120
142
|
{
|
|
121
|
-
path:
|
|
143
|
+
path: tripLogPath,
|
|
122
144
|
value: logs.current.log.total,
|
|
123
145
|
},
|
|
124
146
|
];
|
|
125
147
|
if (options.totals) {
|
|
126
148
|
const base = options.totals_base || 0;
|
|
127
149
|
values.push({
|
|
128
|
-
path:
|
|
150
|
+
path: totalLogPath,
|
|
129
151
|
value: logs.total.log.total + base,
|
|
130
152
|
});
|
|
131
153
|
}
|
|
@@ -141,6 +163,9 @@ module.exports = (app) => {
|
|
|
141
163
|
},
|
|
142
164
|
],
|
|
143
165
|
});
|
|
166
|
+
})
|
|
167
|
+
.catch((err) => {
|
|
168
|
+
app.error(`Error:${err}`);
|
|
144
169
|
});
|
|
145
170
|
}
|
|
146
171
|
|
|
@@ -158,9 +183,53 @@ module.exports = (app) => {
|
|
|
158
183
|
resetTrip();
|
|
159
184
|
setStatus('New trip has started. Log reset');
|
|
160
185
|
}
|
|
186
|
+
})
|
|
187
|
+
.catch((err) => {
|
|
188
|
+
app.error(`Error:${err}`);
|
|
161
189
|
});
|
|
162
190
|
}
|
|
163
191
|
|
|
192
|
+
function sendMeta() {
|
|
193
|
+
// Non-standard paths are not in the Signal K schema, so consumers
|
|
194
|
+
// cannot know their units. Tell the server they are meters.
|
|
195
|
+
const meta = [];
|
|
196
|
+
if (tripLogPath !== 'navigation.trip.log') {
|
|
197
|
+
meta.push({
|
|
198
|
+
path: tripLogPath,
|
|
199
|
+
value: {
|
|
200
|
+
units: 'm',
|
|
201
|
+
displayName: 'Current trip log',
|
|
202
|
+
description: 'Distance travelled during the current trip',
|
|
203
|
+
},
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
if (options.totals && totalLogPath !== 'navigation.log') {
|
|
207
|
+
meta.push({
|
|
208
|
+
path: totalLogPath,
|
|
209
|
+
value: {
|
|
210
|
+
units: 'm',
|
|
211
|
+
displayName: 'Total log',
|
|
212
|
+
description: 'Total distance travelled',
|
|
213
|
+
},
|
|
214
|
+
});
|
|
215
|
+
}
|
|
216
|
+
if (meta.length === 0) {
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
app.handleMessage(plugin.id, {
|
|
220
|
+
context: `vessels.${app.selfId}`,
|
|
221
|
+
updates: [
|
|
222
|
+
{
|
|
223
|
+
source: {
|
|
224
|
+
label: plugin.id,
|
|
225
|
+
},
|
|
226
|
+
timestamp: (new Date().toISOString()),
|
|
227
|
+
meta,
|
|
228
|
+
},
|
|
229
|
+
],
|
|
230
|
+
});
|
|
231
|
+
}
|
|
232
|
+
|
|
164
233
|
app.subscriptionmanager.subscribe(
|
|
165
234
|
subscription,
|
|
166
235
|
unsubscribes,
|
|
@@ -204,6 +273,15 @@ module.exports = (app) => {
|
|
|
204
273
|
},
|
|
205
274
|
);
|
|
206
275
|
|
|
276
|
+
// Load persisted logs eagerly so that a trip ongoing from a previous
|
|
277
|
+
// session is continued when deltas arrive, instead of being reset
|
|
278
|
+
prepareLogs().catch((err) => {
|
|
279
|
+
app.error(`Error:${err}`);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
// Declare units for any non-standard paths we publish to
|
|
283
|
+
sendMeta();
|
|
284
|
+
|
|
207
285
|
setStatus('Waiting for updates');
|
|
208
286
|
};
|
|
209
287
|
|
|
@@ -220,10 +298,20 @@ module.exports = (app) => {
|
|
|
220
298
|
default: 10000,
|
|
221
299
|
title: 'How often to update log, in milliseconds',
|
|
222
300
|
},
|
|
301
|
+
log_path: {
|
|
302
|
+
type: 'string',
|
|
303
|
+
default: 'navigation.trip.log',
|
|
304
|
+
title: 'Signal K path to publish the current trip log to',
|
|
305
|
+
},
|
|
223
306
|
totals: {
|
|
224
307
|
type: 'boolean',
|
|
225
308
|
default: true,
|
|
226
|
-
title: 'Publish a total number in
|
|
309
|
+
title: 'Publish a total number in the total log path',
|
|
310
|
+
},
|
|
311
|
+
totals_path: {
|
|
312
|
+
type: 'string',
|
|
313
|
+
default: 'navigation.log',
|
|
314
|
+
title: 'Signal K path to publish the total log to',
|
|
227
315
|
},
|
|
228
316
|
totals_base: {
|
|
229
317
|
type: 'number',
|
package/package.json
CHANGED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Smoke tests for the Logger: persistence, atomic saves, and recovery
|
|
3
|
+
* from corrupt or empty log files.
|
|
4
|
+
* @file logger.test.js
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
const test = require('node:test');
|
|
8
|
+
const assert = require('node:assert/strict');
|
|
9
|
+
const {
|
|
10
|
+
mkdtempSync,
|
|
11
|
+
readFileSync,
|
|
12
|
+
writeFileSync,
|
|
13
|
+
existsSync,
|
|
14
|
+
} = require('node:fs');
|
|
15
|
+
const { tmpdir } = require('node:os');
|
|
16
|
+
const { join } = require('node:path');
|
|
17
|
+
|
|
18
|
+
const Logger = require('../Logger');
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Build a Logger with a fresh temp directory, a log filename inside it,
|
|
22
|
+
* and an error collector for the onError callback.
|
|
23
|
+
*
|
|
24
|
+
* @returns {{dir: string, filename: string, logger: Logger, errors: string[]}}
|
|
25
|
+
*/
|
|
26
|
+
function makeLogger() {
|
|
27
|
+
const dir = mkdtempSync(join(tmpdir(), 'triplogger-logger-'));
|
|
28
|
+
const filename = join(dir, '2026-09-11.json');
|
|
29
|
+
const errors = [];
|
|
30
|
+
const logger = new Logger(filename, (message) => errors.push(message));
|
|
31
|
+
return {
|
|
32
|
+
dir,
|
|
33
|
+
filename,
|
|
34
|
+
logger,
|
|
35
|
+
errors,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/**
|
|
40
|
+
* Read a file in the logger's directory as JSON.
|
|
41
|
+
*
|
|
42
|
+
* @param {string} dir
|
|
43
|
+
* @param {string} name file name without directory
|
|
44
|
+
* @returns {object}
|
|
45
|
+
*/
|
|
46
|
+
function readJson(dir, name) {
|
|
47
|
+
return JSON.parse(readFileSync(join(dir, name), 'utf-8'));
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
test('save persists the log as valid JSON and leaves no temp files', async () => {
|
|
51
|
+
const { dir, filename, logger } = makeLogger();
|
|
52
|
+
logger.setState('sailing');
|
|
53
|
+
logger.appendTrip(1000);
|
|
54
|
+
await logger.save();
|
|
55
|
+
|
|
56
|
+
const persisted = readJson(dir, '2026-09-11.json');
|
|
57
|
+
assert.strictEqual(persisted.total, 1000);
|
|
58
|
+
assert.strictEqual(persisted.states.sailing, 1000);
|
|
59
|
+
assert.ok(!existsSync(`${filename}.tmp`), 'temp file was left behind');
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
test('load restores a persisted log', async () => {
|
|
63
|
+
const { filename } = makeLogger();
|
|
64
|
+
const first = new Logger(filename);
|
|
65
|
+
first.setState('motoring');
|
|
66
|
+
first.appendTrip(500);
|
|
67
|
+
await first.save();
|
|
68
|
+
|
|
69
|
+
const errors = [];
|
|
70
|
+
const second = new Logger(filename, (m) => errors.push(m));
|
|
71
|
+
await second.load();
|
|
72
|
+
assert.strictEqual(second.toJSON().total, 500);
|
|
73
|
+
assert.strictEqual(second.toJSON().states.motoring, 500);
|
|
74
|
+
assert.strictEqual(errors.length, 0);
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
test('overlapping saves persist the newest state without corrupting the file', async () => {
|
|
78
|
+
const { dir, filename, logger } = makeLogger();
|
|
79
|
+
logger.appendTrip(1);
|
|
80
|
+
const first = logger.save();
|
|
81
|
+
// Mutate while the first save is still in flight
|
|
82
|
+
logger.appendTrip(2);
|
|
83
|
+
const second = logger.save();
|
|
84
|
+
await Promise.all([first, second]);
|
|
85
|
+
|
|
86
|
+
assert.strictEqual(readJson(dir, '2026-09-11.json').total, 3);
|
|
87
|
+
assert.ok(!existsSync(`${filename}.tmp`), 'temp file was left behind');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('load recovers from a truncated log file by backing it up', async () => {
|
|
91
|
+
const {
|
|
92
|
+
dir,
|
|
93
|
+
filename,
|
|
94
|
+
logger,
|
|
95
|
+
errors,
|
|
96
|
+
} = makeLogger();
|
|
97
|
+
const corrupt = '{"started": "2026-09-11T00:00:00.000Z", "total": 50';
|
|
98
|
+
writeFileSync(filename, corrupt, 'utf-8');
|
|
99
|
+
|
|
100
|
+
await logger.load();
|
|
101
|
+
|
|
102
|
+
// The corrupt contents are preserved for manual recovery
|
|
103
|
+
assert.ok(existsSync(`${filename}.corrupt`), 'no .corrupt backup was made');
|
|
104
|
+
assert.strictEqual(readFileSync(`${filename}.corrupt`, 'utf-8'), corrupt);
|
|
105
|
+
// The corruption was reported
|
|
106
|
+
assert.ok(errors.length > 0, 'corruption was not reported via onError');
|
|
107
|
+
assert.match(errors[0], /corrupt/i);
|
|
108
|
+
// The logger works from a fresh log afterwards
|
|
109
|
+
assert.strictEqual(logger.toJSON().total, 0);
|
|
110
|
+
logger.appendTrip(100);
|
|
111
|
+
await logger.save();
|
|
112
|
+
assert.strictEqual(readJson(dir, '2026-09-11.json').total, 100);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test('load recovers from an empty log file', async () => {
|
|
116
|
+
const {
|
|
117
|
+
dir,
|
|
118
|
+
filename,
|
|
119
|
+
logger,
|
|
120
|
+
errors,
|
|
121
|
+
} = makeLogger();
|
|
122
|
+
writeFileSync(filename, '', 'utf-8');
|
|
123
|
+
|
|
124
|
+
await logger.load();
|
|
125
|
+
|
|
126
|
+
assert.ok(existsSync(`${filename}.corrupt`));
|
|
127
|
+
assert.ok(errors.length > 0, 'corruption was not reported via onError');
|
|
128
|
+
logger.appendTrip(100);
|
|
129
|
+
await logger.save();
|
|
130
|
+
assert.strictEqual(readJson(dir, '2026-09-11.json').total, 100);
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('load recovers from a log file containing a JSON scalar', async () => {
|
|
134
|
+
const { filename, logger, errors } = makeLogger();
|
|
135
|
+
writeFileSync(filename, 'null', 'utf-8');
|
|
136
|
+
|
|
137
|
+
await logger.load();
|
|
138
|
+
|
|
139
|
+
assert.ok(existsSync(`${filename}.corrupt`));
|
|
140
|
+
assert.ok(errors.length > 0, 'corruption was not reported via onError');
|
|
141
|
+
assert.strictEqual(logger.toJSON().total, 0);
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
test('load still rejects on read errors', async () => {
|
|
145
|
+
const { filename } = makeLogger();
|
|
146
|
+
const logger = new Logger(filename);
|
|
147
|
+
await assert.rejects(() => logger.load(), /ENOENT/);
|
|
148
|
+
});
|
package/tests/plugin.test.js
CHANGED
|
@@ -260,6 +260,263 @@ test('totals option also publishes navigation.log', async () => {
|
|
|
260
260
|
assert.ok(value > 100, `navigation.log value was ${value}`);
|
|
261
261
|
});
|
|
262
262
|
|
|
263
|
+
test('startup with an ongoing trip continues it instead of resetting to zero', async () => {
|
|
264
|
+
const app = new FakeSignalKApp();
|
|
265
|
+
// An ongoing trip from a previous session: started, never ended.
|
|
266
|
+
seedLog(app, 'current', {
|
|
267
|
+
started: '2026-01-01T00:00:00.000Z',
|
|
268
|
+
ended: null,
|
|
269
|
+
total: 5000,
|
|
270
|
+
states: { sailing: 5000 },
|
|
271
|
+
});
|
|
272
|
+
const plugin = makePlugin(app);
|
|
273
|
+
plugin.start({ totals: false });
|
|
274
|
+
|
|
275
|
+
// Emit state and positions back-to-back, in the same tick, so they
|
|
276
|
+
// race against the async log loading from disk. This used to overwrite
|
|
277
|
+
// current.json with a fresh zeroed log before the saved one was loaded.
|
|
278
|
+
emitDelta(app, {
|
|
279
|
+
context: 'vessels.self',
|
|
280
|
+
updates: [{ values: [{ path: 'navigation.state', value: 'sailing' }] }],
|
|
281
|
+
});
|
|
282
|
+
emitDelta(app, {
|
|
283
|
+
context: 'vessels.self',
|
|
284
|
+
updates: [
|
|
285
|
+
{ values: [{ path: 'navigation.position', value: { latitude: 60.0, longitude: 24.0 } }] },
|
|
286
|
+
],
|
|
287
|
+
});
|
|
288
|
+
emitDelta(app, {
|
|
289
|
+
context: 'vessels.self',
|
|
290
|
+
updates: [
|
|
291
|
+
{ values: [{ path: 'navigation.position', value: { latitude: 60.001, longitude: 24.0 } }] },
|
|
292
|
+
],
|
|
293
|
+
});
|
|
294
|
+
await waitFor(() => app.messages
|
|
295
|
+
.flatMap(({ message }) => message.updates.flatMap((u) => u.values))
|
|
296
|
+
.some((v) => v.path === 'navigation.trip.log'));
|
|
297
|
+
plugin.stop();
|
|
298
|
+
|
|
299
|
+
// The persisted trip must survive startup: no reset to zero, and the
|
|
300
|
+
// newly logged distance accumulates on top of the existing total.
|
|
301
|
+
const current = readLog(app, 'current');
|
|
302
|
+
assert.ok(
|
|
303
|
+
current.total >= 5000,
|
|
304
|
+
`current.total was ${current.total}, expected the ongoing trip to continue`,
|
|
305
|
+
);
|
|
306
|
+
assert.ok(
|
|
307
|
+
!app.statusMessages.some((m) => /new trip/i.test(m.msg)),
|
|
308
|
+
`unexpected new-trip reset at startup: ${JSON.stringify(app.statusMessages)}`,
|
|
309
|
+
);
|
|
310
|
+
|
|
311
|
+
// The published trip log should also continue from the persisted total.
|
|
312
|
+
const tripValues = app.messages
|
|
313
|
+
.flatMap(({ message }) => message.updates.flatMap((u) => u.values))
|
|
314
|
+
.filter((v) => v.path === 'navigation.trip.log');
|
|
315
|
+
assert.ok(tripValues.length > 0, 'no navigation.trip.log delta was emitted');
|
|
316
|
+
assert.ok(
|
|
317
|
+
tripValues[tripValues.length - 1].value >= 5000,
|
|
318
|
+
`last trip.log value was ${tripValues[tripValues.length - 1].value}`,
|
|
319
|
+
);
|
|
320
|
+
});
|
|
321
|
+
|
|
322
|
+
/**
|
|
323
|
+
* Flatten all value objects the app has received from the plugin.
|
|
324
|
+
* Meta-only messages have no `values`, so those updates are skipped.
|
|
325
|
+
*
|
|
326
|
+
* @param {FakeSignalKApp} app
|
|
327
|
+
* @returns {Array<{path: string, value: number}>}
|
|
328
|
+
*/
|
|
329
|
+
function emittedValues(app) {
|
|
330
|
+
return app.messages.flatMap(
|
|
331
|
+
({ message }) => message.updates.filter((u) => u.values).flatMap((u) => u.values),
|
|
332
|
+
);
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* Flatten all meta entries the app has received from the plugin.
|
|
337
|
+
*
|
|
338
|
+
* @param {FakeSignalKApp} app
|
|
339
|
+
* @returns {Array<{path: string, value: object}>}
|
|
340
|
+
*/
|
|
341
|
+
function emittedMeta(app) {
|
|
342
|
+
return app.messages.flatMap(
|
|
343
|
+
({ message }) => message.updates.filter((u) => u.meta).flatMap((u) => u.meta),
|
|
344
|
+
);
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
test('custom log_path publishes the trip log under the configured path', async () => {
|
|
348
|
+
const app = new FakeSignalKApp();
|
|
349
|
+
seedLog(app, 'current', {
|
|
350
|
+
started: '2026-01-01T00:00:00.000Z',
|
|
351
|
+
ended: '2026-01-01T01:00:00.000Z',
|
|
352
|
+
total: 0,
|
|
353
|
+
states: {},
|
|
354
|
+
});
|
|
355
|
+
const plugin = makePlugin(app);
|
|
356
|
+
plugin.start({
|
|
357
|
+
log_path: 'navigation.trip.currentTripLog',
|
|
358
|
+
totals: false,
|
|
359
|
+
});
|
|
360
|
+
emitDelta(app, {
|
|
361
|
+
context: 'vessels.self',
|
|
362
|
+
updates: [{ values: [{ path: 'navigation.state', value: 'sailing' }] }],
|
|
363
|
+
});
|
|
364
|
+
await waitFor(
|
|
365
|
+
() => app.statusMessages.some((m) => /new trip/i.test(m.msg)),
|
|
366
|
+
);
|
|
367
|
+
emitDelta(app, {
|
|
368
|
+
context: 'vessels.self',
|
|
369
|
+
updates: [
|
|
370
|
+
{ values: [{ path: 'navigation.position', value: { latitude: 60.0, longitude: 24.0 } }] },
|
|
371
|
+
],
|
|
372
|
+
});
|
|
373
|
+
emitDelta(app, {
|
|
374
|
+
context: 'vessels.self',
|
|
375
|
+
updates: [
|
|
376
|
+
{ values: [{ path: 'navigation.position', value: { latitude: 60.001, longitude: 24.0 } }] },
|
|
377
|
+
],
|
|
378
|
+
});
|
|
379
|
+
await waitFor(
|
|
380
|
+
() => emittedValues(app).some(
|
|
381
|
+
(v) => v.path === 'navigation.trip.currentTripLog' && v.value > 0,
|
|
382
|
+
),
|
|
383
|
+
);
|
|
384
|
+
plugin.stop();
|
|
385
|
+
|
|
386
|
+
// The trip log is published under the configured custom path...
|
|
387
|
+
assert.ok(
|
|
388
|
+
emittedValues(app).some((v) => v.path === 'navigation.trip.currentTripLog'),
|
|
389
|
+
'no delta was emitted to navigation.trip.currentTripLog',
|
|
390
|
+
);
|
|
391
|
+
// ...and not under the standard path
|
|
392
|
+
assert.ok(
|
|
393
|
+
!emittedValues(app).some((v) => v.path === 'navigation.trip.log'),
|
|
394
|
+
'navigation.trip.log should not be written when log_path is set',
|
|
395
|
+
);
|
|
396
|
+
// The non-standard path is announced with units metadata
|
|
397
|
+
const tripMeta = emittedMeta(app).find((m) => m.path === 'navigation.trip.currentTripLog');
|
|
398
|
+
assert.ok(tripMeta, 'no meta was sent for the custom trip log path');
|
|
399
|
+
assert.strictEqual(tripMeta.value.units, 'm');
|
|
400
|
+
});
|
|
401
|
+
|
|
402
|
+
test('custom totals_path publishes the total under the configured path', async () => {
|
|
403
|
+
const app = new FakeSignalKApp();
|
|
404
|
+
seedLog(app, 'current', {
|
|
405
|
+
started: '2026-01-01T00:00:00.000Z',
|
|
406
|
+
ended: '2026-01-01T01:00:00.000Z',
|
|
407
|
+
total: 0,
|
|
408
|
+
states: {},
|
|
409
|
+
});
|
|
410
|
+
const plugin = makePlugin(app);
|
|
411
|
+
plugin.start({
|
|
412
|
+
totals: true,
|
|
413
|
+
totals_base: 100,
|
|
414
|
+
totals_path: 'navigation.log.clubTotal',
|
|
415
|
+
});
|
|
416
|
+
emitDelta(app, {
|
|
417
|
+
context: 'vessels.self',
|
|
418
|
+
updates: [{ values: [{ path: 'navigation.state', value: 'motoring' }] }],
|
|
419
|
+
});
|
|
420
|
+
await waitFor(
|
|
421
|
+
() => app.statusMessages.some((m) => /new trip/i.test(m.msg)),
|
|
422
|
+
);
|
|
423
|
+
emitDelta(app, {
|
|
424
|
+
context: 'vessels.self',
|
|
425
|
+
updates: [
|
|
426
|
+
{ values: [{ path: 'navigation.position', value: { latitude: 60.0, longitude: 24.0 } }] },
|
|
427
|
+
],
|
|
428
|
+
});
|
|
429
|
+
emitDelta(app, {
|
|
430
|
+
context: 'vessels.self',
|
|
431
|
+
updates: [
|
|
432
|
+
{ values: [{ path: 'navigation.position', value: { latitude: 60.002, longitude: 24.0 } }] },
|
|
433
|
+
],
|
|
434
|
+
});
|
|
435
|
+
await waitFor(
|
|
436
|
+
() => emittedValues(app).some(
|
|
437
|
+
(v) => v.path === 'navigation.log.clubTotal' && v.value > 100,
|
|
438
|
+
),
|
|
439
|
+
);
|
|
440
|
+
plugin.stop();
|
|
441
|
+
|
|
442
|
+
// The total is published under the configured custom path...
|
|
443
|
+
assert.ok(
|
|
444
|
+
emittedValues(app).some((v) => v.path === 'navigation.log.clubTotal'),
|
|
445
|
+
'no delta was emitted to navigation.log.clubTotal',
|
|
446
|
+
);
|
|
447
|
+
// ...and not under the standard path
|
|
448
|
+
assert.ok(
|
|
449
|
+
!emittedValues(app).some((v) => v.path === 'navigation.log'),
|
|
450
|
+
'navigation.log should not be written when totals_path is set',
|
|
451
|
+
);
|
|
452
|
+
// The trip log keeps its standard path unless configured otherwise
|
|
453
|
+
assert.ok(
|
|
454
|
+
emittedValues(app).some((v) => v.path === 'navigation.trip.log'),
|
|
455
|
+
'navigation.trip.log should still be emitted with only totals_path set',
|
|
456
|
+
);
|
|
457
|
+
// The non-standard path is announced with units metadata
|
|
458
|
+
const totalMeta = emittedMeta(app).find((m) => m.path === 'navigation.log.clubTotal');
|
|
459
|
+
assert.ok(totalMeta, 'no meta was sent for the custom totals path');
|
|
460
|
+
assert.strictEqual(totalMeta.value.units, 'm');
|
|
461
|
+
// No meta is needed for the standard trip path
|
|
462
|
+
assert.ok(
|
|
463
|
+
!emittedMeta(app).some((m) => m.path === 'navigation.trip.log'),
|
|
464
|
+
'standard paths should not get meta',
|
|
465
|
+
);
|
|
466
|
+
});
|
|
467
|
+
|
|
468
|
+
test('standard paths send no meta messages', () => {
|
|
469
|
+
const { app, plugin } = makeStarted();
|
|
470
|
+
assert.strictEqual(emittedMeta(app).length, 0);
|
|
471
|
+
plugin.stop();
|
|
472
|
+
});
|
|
473
|
+
|
|
474
|
+
test('a corrupt persisted log is backed up and logging continues', async () => {
|
|
475
|
+
const app = new FakeSignalKApp();
|
|
476
|
+
// A log truncated by a crash mid-write. With the old code this threw
|
|
477
|
+
// an uncaught exception from the readFile callback, killing the whole
|
|
478
|
+
// server process on every startup.
|
|
479
|
+
const corrupt = '{"started": "2026-09-11T00:00:00.000Z", "total": 5000';
|
|
480
|
+
writeFileSync(join(app.dataPath, 'current.json'), corrupt, 'utf-8');
|
|
481
|
+
const plugin = makePlugin(app);
|
|
482
|
+
plugin.start({ totals: false });
|
|
483
|
+
|
|
484
|
+
emitDelta(app, {
|
|
485
|
+
context: 'vessels.self',
|
|
486
|
+
updates: [{ values: [{ path: 'navigation.state', value: 'sailing' }] }],
|
|
487
|
+
});
|
|
488
|
+
emitDelta(app, {
|
|
489
|
+
context: 'vessels.self',
|
|
490
|
+
updates: [
|
|
491
|
+
{ values: [{ path: 'navigation.position', value: { latitude: 60.0, longitude: 24.0 } }] },
|
|
492
|
+
],
|
|
493
|
+
});
|
|
494
|
+
emitDelta(app, {
|
|
495
|
+
context: 'vessels.self',
|
|
496
|
+
updates: [
|
|
497
|
+
{ values: [{ path: 'navigation.position', value: { latitude: 60.001, longitude: 24.0 } }] },
|
|
498
|
+
],
|
|
499
|
+
});
|
|
500
|
+
await waitFor(() => app.messages
|
|
501
|
+
.flatMap(({ message }) => message.updates.flatMap((u) => u.values))
|
|
502
|
+
.some((v) => v.path === 'navigation.trip.log' && v.value > 0));
|
|
503
|
+
plugin.stop();
|
|
504
|
+
|
|
505
|
+
// The corrupt contents are preserved for manual recovery...
|
|
506
|
+
assert.strictEqual(
|
|
507
|
+
readFileSync(join(app.dataPath, 'current.json.corrupt'), 'utf-8'),
|
|
508
|
+
corrupt,
|
|
509
|
+
);
|
|
510
|
+
// ...and the corruption was reported
|
|
511
|
+
assert.ok(
|
|
512
|
+
app.errors.some((e) => /corrupt/i.test(e)),
|
|
513
|
+
`got ${JSON.stringify(app.errors)}`,
|
|
514
|
+
);
|
|
515
|
+
// Logging continues from a fresh log
|
|
516
|
+
const current = readLog(app, 'current');
|
|
517
|
+
assert.ok(current.total > 0, `current.total was ${current.total}`);
|
|
518
|
+
});
|
|
519
|
+
|
|
263
520
|
test('stop clears subscriptions', () => {
|
|
264
521
|
const { app, plugin } = makeStarted();
|
|
265
522
|
assert.strictEqual(app.subscriptionmanager.subscriptions.length, 1);
|