dsh-offwork 1.1.1 → 1.1.2
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 +12 -0
- package/README.md +36 -21
- package/client.js +2558 -822
- package/client.source.js +1592 -432
- package/companion.js +6 -9
- package/countdown.js +83 -0
- package/hydration-store.js +55 -16
- package/hydration.js +231 -250
- package/index.js +2 -0
- package/locale/en.json +1 -1
- package/locale/zh.json +1 -1
- package/media-manifest.js +29 -0
- package/media-server.js +123 -0
- package/package.json +6 -2
- package/pet-interactions.js +80 -9
- package/pet-motion.js +2 -1
- package/pet-reminder.js +20 -0
- package/pet-surfaces.js +129 -5
- package/pet-video.js +221 -72
- package/pet-view.js +69 -4
- package/tests/classic-overview.test.mjs +77 -0
- package/tests/companion.test.mjs +12 -12
- package/tests/countdown.test.mjs +115 -0
- package/tests/host.test.mjs +6 -0
- package/tests/hydration-extras.test.mjs +168 -204
- package/tests/hydration-store.test.mjs +199 -213
- package/tests/hydration.test.mjs +116 -186
- package/tests/media-server.test.mjs +179 -0
- package/tests/pet-reminder.test.mjs +71 -0
- package/tests/pet-video.test.mjs +171 -5
- package/tools/build.mjs +39 -16
- package/tools/check-package.mjs +8 -0
- package/ui-utils.js +44 -0
package/tests/hydration.test.mjs
CHANGED
|
@@ -1,211 +1,141 @@
|
|
|
1
1
|
import test from 'node:test';
|
|
2
2
|
import assert from 'node:assert/strict';
|
|
3
|
-
import { calculate } from '../clock.js';
|
|
4
3
|
import {
|
|
5
4
|
defaultHydration,
|
|
6
5
|
validateHydration,
|
|
7
|
-
hydrationContext,
|
|
8
6
|
reconcileHydration,
|
|
9
7
|
hydrationView,
|
|
10
8
|
transitionHydration,
|
|
11
9
|
HYDRATION_LEASE_MS,
|
|
12
10
|
} from '../hydration.js';
|
|
11
|
+
const time = (value) => Date.parse(value.includes('T') ? value : `2026-11-06T${value}:00+08:00`);
|
|
12
|
+
const act = (state, at, action, owner = 'a') =>
|
|
13
|
+
transitionHydration(state, action, typeof at === 'number' ? at : time(at), owner);
|
|
14
|
+
const enable = (at = '09:00', interval = 60) =>
|
|
15
|
+
act(defaultHydration(), at, { type: 'preferences', enabled: true, interval });
|
|
16
|
+
const view = (state, at) => hydrationView(state, typeof at === 'number' ? at : time(at));
|
|
13
17
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
end: '18:00',
|
|
21
|
-
breakStart: '12:00',
|
|
22
|
-
breakEnd: '13:00',
|
|
23
|
-
weekdays: [1, 2, 3, 4, 5],
|
|
24
|
-
timezone: 'Asia/Shanghai',
|
|
25
|
-
};
|
|
26
|
-
const time = (text) => Date.parse(text.includes('T') ? text : `2026-11-04T${text}:00+08:00`);
|
|
27
|
-
const context = (text, patch = {}) => {
|
|
28
|
-
const config = { ...settings, ...patch };
|
|
29
|
-
return hydrationContext(config, calculate(config, time(text)));
|
|
30
|
-
};
|
|
31
|
-
const act = (state, at, action, patch = {}, owner = 'tab-a') =>
|
|
32
|
-
transitionHydration(state, context(at, patch), action, time(at), owner);
|
|
33
|
-
const enable = (at = '09:00', interval = 60, patch = {}) =>
|
|
34
|
-
act(defaultHydration(), at, { type: 'preferences', enabled: true, interval }, patch);
|
|
35
|
-
const view = (state, at, patch = {}) =>
|
|
36
|
-
hydrationView(reconcileHydration(state, context(at, patch)), context(at, patch), time(at));
|
|
37
|
-
const claim = (state, at, patch = {}, owner = 'tab-a') =>
|
|
38
|
-
act(state, at, { type: 'claim' }, patch, owner);
|
|
39
|
-
|
|
40
|
-
test('off by default; enabling mid-shift starts a full interval, not catch-up', () => {
|
|
41
|
-
assert.equal(view(defaultHydration(), '11:45').phase, 'off');
|
|
42
|
-
const state = enable('10:15');
|
|
43
|
-
assert.equal(view(state, '10:15').remaining, 3600000);
|
|
44
|
-
assert.equal(view(state, '11:14').phase, 'countdown');
|
|
45
|
-
assert.equal(view(state, '11:15').phase, 'due');
|
|
18
|
+
test('reminders are opt-in and enabling at any hour starts one full wall-clock interval', () => {
|
|
19
|
+
assert.equal(view(defaultHydration(), '23:00').phase, 'off');
|
|
20
|
+
const state = enable('23:15');
|
|
21
|
+
assert.equal(state.dueAt, time('2026-11-07T00:15:00+08:00'));
|
|
22
|
+
assert.equal(view(state, '23:59').phase, 'countdown');
|
|
23
|
+
assert.equal(view(state, '2026-11-07T00:15:00+08:00').phase, 'due');
|
|
46
24
|
});
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
const initial = enable('09:00');
|
|
25
|
+
for (const interval of [5, 15, 17, 30, 45, 60, 90, 120, 240])
|
|
26
|
+
test(`saving ${interval} natural minutes preserves unchanged settings and applies changed intervals`, () => {
|
|
27
|
+
const initial = enable();
|
|
51
28
|
const saved = act(initial, '09:10', { type: 'preferences', enabled: true, interval });
|
|
52
|
-
assert.equal(saved.
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
assert.equal(saved.shift.due, anchor + interval * 60000);
|
|
57
|
-
const reopened = validateHydration(JSON.parse(JSON.stringify(saved)));
|
|
58
|
-
assert.equal(reopened.interval, interval);
|
|
59
|
-
const deadline =
|
|
60
|
-
time('09:00') + saved.shift.due + (saved.shift.due >= 3 * 3600000 ? 3600000 : 0);
|
|
61
|
-
const before = hydrationContext(settings, calculate(settings, deadline - 1));
|
|
62
|
-
const atDue = hydrationContext(settings, calculate(settings, deadline));
|
|
63
|
-
assert.equal(hydrationView(reopened, before).phase, 'countdown');
|
|
64
|
-
assert.equal(hydrationView(reopened, atDue).phase, 'due');
|
|
29
|
+
assert.equal(saved.dueAt, time(interval === 60 ? '09:00' : '09:10') + interval * 60000);
|
|
30
|
+
const restored = validateHydration(JSON.parse(JSON.stringify(saved)));
|
|
31
|
+
assert.equal(view(restored, saved.dueAt - 1).phase, 'countdown');
|
|
32
|
+
assert.equal(view(restored, saved.dueAt).phase, 'due');
|
|
65
33
|
});
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
});
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
state = act(state, '10:03', { type: 'drink', eventId });
|
|
86
|
-
assert.equal(state.shift.count, 1);
|
|
87
|
-
assert.equal(view(state, '11:02').phase, 'countdown');
|
|
88
|
-
assert.equal(view(state, '11:03').phase, 'due');
|
|
34
|
+
for (const [start, end] of [
|
|
35
|
+
['11:50', '12:50'],
|
|
36
|
+
['17:50', '18:50'],
|
|
37
|
+
['2026-11-07T10:00:00+08:00', '2026-11-07T11:00:00+08:00'],
|
|
38
|
+
])
|
|
39
|
+
test(`water continues from ${start} to ${end} without lunch or workday gating`, () => {
|
|
40
|
+
const state = enable(start);
|
|
41
|
+
assert.equal(view(state, time(start) + 1800000).remaining, 1800000);
|
|
42
|
+
assert.equal(view(state, end).phase, 'due');
|
|
43
|
+
});
|
|
44
|
+
test('snooze uses ten clock minutes across lunch and shift boundaries', () => {
|
|
45
|
+
for (const at of ['11:58', '17:58']) {
|
|
46
|
+
let state = enable(typeof at === 'string' ? time(at) - 3600000 : at);
|
|
47
|
+
state = act(state, at, { type: 'claim' });
|
|
48
|
+
const next = act(state, at, { type: 'snooze', eventId: view(state, at).eventId });
|
|
49
|
+
assert.equal(next.dueAt, time(at) + 600000);
|
|
50
|
+
assert.equal(next.count, 0);
|
|
51
|
+
assert.equal(view(next, next.dueAt).phase, 'due');
|
|
52
|
+
}
|
|
89
53
|
});
|
|
90
|
-
|
|
91
|
-
test('hours of missed ticks yield a single stable event; shift end discards it', () => {
|
|
54
|
+
test('missed ticks and calendar rollover keep one overdue event rather than rescheduling', () => {
|
|
92
55
|
const state = enable();
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
56
|
+
const event = view(state, '10:00').eventId;
|
|
57
|
+
for (const at of ['18:00', '2026-11-07T12:00:00+08:00', '2026-11-09T09:00:00+08:00']) {
|
|
58
|
+
const next = reconcileHydration(state, time(at));
|
|
59
|
+
assert.equal(view(next, at).phase, 'due');
|
|
60
|
+
assert.equal(view(next, at).eventId, event);
|
|
61
|
+
assert.equal(next.dueAt, state.dueAt);
|
|
62
|
+
}
|
|
97
63
|
});
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
const
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
assert.equal(
|
|
104
|
-
assert.equal(
|
|
105
|
-
|
|
106
|
-
assert.equal(hydrationView(next, context('2026-11-09T22:00:00+08:00', night)).phase, 'countdown');
|
|
107
|
-
assert.equal(next.shift.paused, false);
|
|
64
|
+
test('a confirmed reminder records once and schedules from the accepted action time', () => {
|
|
65
|
+
let state = act(enable(), '18:05', { type: 'claim' });
|
|
66
|
+
const eventId = view(state, '18:05').eventId;
|
|
67
|
+
state = act(state, '18:05', { type: 'drink', eventId });
|
|
68
|
+
const replay = act(state, '18:06', { type: 'drink', eventId });
|
|
69
|
+
assert.equal(replay.count, 1);
|
|
70
|
+
assert.equal(replay.dueAt, time('19:05'));
|
|
71
|
+
assert.deepEqual(replay, state);
|
|
108
72
|
});
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
assert.equal(
|
|
117
|
-
state = act(state, '10:10', { type: 'pause' });
|
|
118
|
-
state = act(state, '10:40', { type: 'resume' });
|
|
119
|
-
assert.equal(view(state, '10:40').remaining, 3600000);
|
|
120
|
-
state = act(state, '10:50', { type: 'preferences', enabled: true, interval: 5 });
|
|
121
|
-
assert.equal(view(state, '10:50').remaining, 300000);
|
|
122
|
-
assert.equal(view(state, '10:55').phase, 'due');
|
|
73
|
+
test('pause lasts until explicit resume and neither dates nor days off reset the count', () => {
|
|
74
|
+
let state = act(enable(), '17:30', { type: 'manual-drink', actionId: 'first' });
|
|
75
|
+
state = act(state, '17:40', { type: 'pause' });
|
|
76
|
+
const later = reconcileHydration(state, time('2026-11-09T09:00:00+08:00'));
|
|
77
|
+
assert.equal(view(later, '2026-11-09T09:00:00+08:00').phase, 'paused');
|
|
78
|
+
assert.equal(later.count, 1);
|
|
79
|
+
const resumed = act(later, '2026-11-09T09:00:00+08:00', { type: 'resume' });
|
|
80
|
+
assert.equal(resumed.dueAt, time('2026-11-09T10:00:00+08:00'));
|
|
123
81
|
});
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
|
|
129
|
-
assert.equal(
|
|
130
|
-
reconcileHydration(state, context('10:30', { monthlySalary: 15000 })).shift.due,
|
|
131
|
-
oldDue,
|
|
132
|
-
);
|
|
133
|
-
state = act(state, '10:30', { type: 'pause' });
|
|
134
|
-
const shifted = reconcileHydration(state, context('10:45', { end: '19:00' }));
|
|
135
|
-
assert.equal(shifted.shift.count, 1);
|
|
136
|
-
assert.equal(shifted.shift.paused, true);
|
|
137
|
-
assert.equal(shifted.shift.due - context('10:45', { end: '19:00' }).elapsed, 3600000);
|
|
82
|
+
test('clear-count does not move the deadline, change quiet/pause, or erase deduplication IDs', () => {
|
|
83
|
+
let state = act(enable(), '09:10', { type: 'manual-drink', actionId: 'keep' });
|
|
84
|
+
state = act(state, '09:11', { type: 'quiet', minutes: 60 });
|
|
85
|
+
const cleared = act(state, '09:12', { type: 'clear-count' });
|
|
86
|
+
assert.deepEqual(cleared, { ...state, count: 0 });
|
|
87
|
+
assert.equal(act(cleared, '09:13', { type: 'manual-drink', actionId: 'keep' }).count, 0);
|
|
138
88
|
});
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
let state = claim(enable(), '10:00');
|
|
89
|
+
test('one lease owner handles a due event, ownership expires, and foreign release cannot steal it', () => {
|
|
90
|
+
const state = act(enable(), '10:00', { type: 'claim' });
|
|
142
91
|
const eventId = view(state, '10:00').eventId;
|
|
143
|
-
|
|
144
|
-
assert.equal(state
|
|
145
|
-
|
|
146
|
-
const
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
assert.equal(
|
|
150
|
-
assert.equal(
|
|
151
|
-
transitionHydration(state, ctx, { type: 'drink', eventId }, at, 'tab-a').shift.count,
|
|
152
|
-
0,
|
|
153
|
-
);
|
|
154
|
-
assert.equal(
|
|
155
|
-
transitionHydration(state, ctx, { type: 'drink', eventId }, at, 'tab-b').shift.count,
|
|
156
|
-
1,
|
|
157
|
-
);
|
|
92
|
+
assert.deepEqual(act(state, '10:00', { type: 'release' }, 'b'), state);
|
|
93
|
+
assert.equal(act(state, '10:00', { type: 'drink', eventId }, 'b').count, 0);
|
|
94
|
+
const now = time('10:00') + HYDRATION_LEASE_MS;
|
|
95
|
+
const next = act(state, now, { type: 'claim' }, 'b');
|
|
96
|
+
assert.equal(next.claim.owner, 'b');
|
|
97
|
+
assert.equal(act(next, now, { type: 'drink', eventId }, 'a').count, 0);
|
|
98
|
+
assert.equal(act(next, now, { type: 'drink', eventId }, 'b').count, 1);
|
|
158
99
|
});
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
);
|
|
167
|
-
state = claim(enable('11:00'), '11:59');
|
|
168
|
-
assert.equal(reconcileHydration(state, context('12:00')).shift.claim, null);
|
|
169
|
-
assert.deepEqual(act(state, '12:00', { type: 'reset' }), defaultHydration());
|
|
170
|
-
});
|
|
171
|
-
|
|
172
|
-
test('at exact shift end there is no reminder; first opening tomorrow starts fresh', () => {
|
|
173
|
-
const state = enable('17:00');
|
|
174
|
-
assert.equal(view(state, '17:00').phase, 'finished');
|
|
175
|
-
assert.notEqual(view(state, '18:00').phase, 'due');
|
|
176
|
-
assert.equal(view(state, '2026-11-05T10:25:00+08:00').remaining, 3600000);
|
|
100
|
+
test('expired, future and released claims are recoverable without changing the event', () => {
|
|
101
|
+
let state = act(enable(), '10:00', { type: 'claim' });
|
|
102
|
+
const eventId = view(state, '10:00').eventId;
|
|
103
|
+
state.claim.until = time('10:00') + HYDRATION_LEASE_MS + 1;
|
|
104
|
+
const replaced = act(state, '10:00', { type: 'claim' }, 'b');
|
|
105
|
+
assert.equal(replaced.claim.owner, 'b');
|
|
106
|
+
assert.equal(view(replaced, '10:00').eventId, eventId);
|
|
107
|
+
assert.equal(act(replaced, '10:00', { type: 'release' }, 'b').claim, null);
|
|
177
108
|
});
|
|
178
|
-
|
|
179
|
-
|
|
180
|
-
const
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
weekdays: [6],
|
|
187
|
-
};
|
|
188
|
-
const spring = enable('2026-03-08T06:30:00Z', 60, patch);
|
|
189
|
-
assert.equal(view(spring, '2026-03-08T07:00:00Z', patch).remaining, 1800000);
|
|
190
|
-
assert.equal(view(spring, '2026-03-08T07:30:00Z', patch).phase, 'due');
|
|
191
|
-
const fall = enable('2026-11-01T05:30:00Z', 60, patch);
|
|
192
|
-
assert.equal(view(fall, '2026-11-01T06:30:00Z', patch).phase, 'due');
|
|
109
|
+
test('preference changes invalidate stale confirmations; partial updates preserve the other preference', () => {
|
|
110
|
+
let state = act(enable(), '10:00', { type: 'claim' });
|
|
111
|
+
const eventId = view(state, '10:00').eventId;
|
|
112
|
+
state = act(state, '10:00', { type: 'preferences', interval: 30 });
|
|
113
|
+
assert.equal(act(state, '10:00', { type: 'drink', eventId }).count, 0);
|
|
114
|
+
state = act(state, '10:01', { type: 'preferences', enabled: false });
|
|
115
|
+
assert.equal(state.interval, 30);
|
|
116
|
+
assert.equal(state.claim, null);
|
|
193
117
|
});
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
|
|
198
|
-
{},
|
|
199
|
-
|
|
200
|
-
{
|
|
201
|
-
,
|
|
202
|
-
{
|
|
203
|
-
{
|
|
204
|
-
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
for (const interval of [0, 4, 241, 5.5
|
|
208
|
-
|
|
209
|
-
assert.
|
|
210
|
-
|
|
118
|
+
test('invalid stored fields, preferences and overflowing timestamps fail closed', () => {
|
|
119
|
+
for (const patch of [
|
|
120
|
+
{ schema: 99 },
|
|
121
|
+
{ interval: 4 },
|
|
122
|
+
{ count: -1 },
|
|
123
|
+
{ paused: 0 },
|
|
124
|
+
{ dueAt: NaN },
|
|
125
|
+
{ quietUntil: -1 },
|
|
126
|
+
{ recentActions: ['x', 'x'] },
|
|
127
|
+
{ claim: { owner: '', until: 1, sequence: 0 } },
|
|
128
|
+
{ unknown: true },
|
|
129
|
+
])
|
|
130
|
+
assert.throws(() => validateHydration({ ...defaultHydration(), ...patch }));
|
|
131
|
+
for (const interval of [0, 4, 241, NaN, 5.5])
|
|
132
|
+
assert.throws(() => act(defaultHydration(), '09:00', { type: 'preferences', interval }));
|
|
133
|
+
assert.throws(() =>
|
|
134
|
+
transitionHydration(
|
|
135
|
+
defaultHydration(),
|
|
136
|
+
{ type: 'preferences', enabled: true },
|
|
137
|
+
Number.MAX_SAFE_INTEGER,
|
|
138
|
+
'a',
|
|
139
|
+
),
|
|
140
|
+
);
|
|
211
141
|
});
|
|
@@ -0,0 +1,179 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { createServer } from 'node:http';
|
|
4
|
+
import { mkdtemp, writeFile, rm, symlink } from 'node:fs/promises';
|
|
5
|
+
import { tmpdir } from 'node:os';
|
|
6
|
+
import { join } from 'node:path';
|
|
7
|
+
import { pathToFileURL } from 'node:url';
|
|
8
|
+
import { createHash } from 'node:crypto';
|
|
9
|
+
import { createMediaHandler, mediaRange, registerMediaRoute } from '../media-server.js';
|
|
10
|
+
import { MEDIA_ROUTE } from '../media-manifest.js';
|
|
11
|
+
import { petClipUrls } from '../pet-video.js';
|
|
12
|
+
|
|
13
|
+
async function fixture(t, corrupt = false) {
|
|
14
|
+
const dir = await mkdtemp(join(tmpdir(), 'offwork-media-'));
|
|
15
|
+
const bytes = Buffer.from(Array.from({ length: 256 }, (_, i) => i));
|
|
16
|
+
const sha256 = createHash('sha256').update(bytes).digest('hex');
|
|
17
|
+
const key = sha256 + '/clip.webm';
|
|
18
|
+
await writeFile(join(dir, 'clip.webm'), corrupt ? Buffer.alloc(bytes.length) : bytes);
|
|
19
|
+
await writeFile(join(dir, 'data.json'), 'private fixture, never serve');
|
|
20
|
+
const assets = { [key]: { file: 'clip.webm', size: bytes.length, sha256 } };
|
|
21
|
+
const server = createServer(createMediaHandler({ assets, root: pathToFileURL(dir + '/') }));
|
|
22
|
+
await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));
|
|
23
|
+
t.after(async () => {
|
|
24
|
+
server.closeAllConnections();
|
|
25
|
+
await new Promise((resolve) => server.close(resolve));
|
|
26
|
+
await rm(dir, { recursive: true, force: true });
|
|
27
|
+
});
|
|
28
|
+
return {
|
|
29
|
+
dir,
|
|
30
|
+
bytes,
|
|
31
|
+
sha256,
|
|
32
|
+
key,
|
|
33
|
+
origin: `http://127.0.0.1:${server.address().port}`,
|
|
34
|
+
url: `http://127.0.0.1:${server.address().port}${MEDIA_ROUTE}/${key}`,
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
test('media endpoint serves immutable verified bytes, HEAD and conditional responses', async (t) => {
|
|
39
|
+
const f = await fixture(t);
|
|
40
|
+
const response = await fetch(f.url);
|
|
41
|
+
assert.equal(response.status, 200);
|
|
42
|
+
assert.equal(response.headers.get('content-type'), 'video/webm');
|
|
43
|
+
assert.match(response.headers.get('cache-control'), /immutable/);
|
|
44
|
+
assert.equal(response.headers.get('accept-ranges'), 'bytes');
|
|
45
|
+
assert.equal(response.headers.get('cross-origin-resource-policy'), 'same-origin');
|
|
46
|
+
assert.deepEqual(Buffer.from(await response.arrayBuffer()), f.bytes);
|
|
47
|
+
const head = await fetch(f.url, { method: 'HEAD', headers: { Range: 'bytes=10-20' } });
|
|
48
|
+
assert.equal(head.status, 200);
|
|
49
|
+
assert.equal(head.headers.get('content-length'), '256');
|
|
50
|
+
assert.equal((await head.arrayBuffer()).byteLength, 0);
|
|
51
|
+
const cached = await fetch(f.url, {
|
|
52
|
+
headers: { 'If-None-Match': `W/"${f.sha256}"`, Range: 'bytes=999-' },
|
|
53
|
+
});
|
|
54
|
+
assert.equal(cached.status, 304);
|
|
55
|
+
assert.equal((await cached.arrayBuffer()).byteLength, 0);
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
test('single byte ranges, suffixes and If-Range allow seeking without loading other resources', async (t) => {
|
|
59
|
+
const f = await fixture(t);
|
|
60
|
+
for (const [header, start, end] of [
|
|
61
|
+
['bytes=20-31', 20, 31],
|
|
62
|
+
['bytes=-7', 249, 255],
|
|
63
|
+
['bytes=250-', 250, 255],
|
|
64
|
+
['bytes=250-999', 250, 255],
|
|
65
|
+
]) {
|
|
66
|
+
const response = await fetch(f.url, {
|
|
67
|
+
headers: { Range: header, 'If-Range': `"${f.sha256}"` },
|
|
68
|
+
});
|
|
69
|
+
assert.equal(response.status, 206);
|
|
70
|
+
assert.equal(response.headers.get('content-range'), `bytes ${start}-${end}/256`);
|
|
71
|
+
assert.deepEqual(Buffer.from(await response.arrayBuffer()), f.bytes.subarray(start, end + 1));
|
|
72
|
+
}
|
|
73
|
+
const changed = await fetch(f.url, { headers: { Range: 'bytes=1-2', 'If-Range': '"other"' } });
|
|
74
|
+
assert.equal(changed.status, 200);
|
|
75
|
+
assert.deepEqual(Buffer.from(await changed.arrayBuffer()), f.bytes);
|
|
76
|
+
for (const header of [
|
|
77
|
+
'bytes=256-',
|
|
78
|
+
'bytes=-0',
|
|
79
|
+
'bytes=4-2',
|
|
80
|
+
'bytes=0-1,4-5',
|
|
81
|
+
'bytes=9999999999999999999999-',
|
|
82
|
+
]) {
|
|
83
|
+
const response = await fetch(f.url, { headers: { Range: header } });
|
|
84
|
+
assert.equal(response.status, 416);
|
|
85
|
+
assert.equal(response.headers.get('content-range'), 'bytes */256');
|
|
86
|
+
}
|
|
87
|
+
assert.equal(mediaRange(['bytes=0-1'], 256), null);
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
test('unknown names, traversal and mutating requests never expose adjacent files', async (t) => {
|
|
91
|
+
const f = await fixture(t);
|
|
92
|
+
for (const path of [
|
|
93
|
+
'data.json',
|
|
94
|
+
'constructor',
|
|
95
|
+
'%2e%2e/data.json',
|
|
96
|
+
'a/../../../data.json',
|
|
97
|
+
f.key + '/../data.json',
|
|
98
|
+
'%2fdata.json',
|
|
99
|
+
]) {
|
|
100
|
+
const response = await fetch(f.origin + MEDIA_ROUTE + '/' + path);
|
|
101
|
+
assert.equal(response.status, 404);
|
|
102
|
+
assert.equal(await response.text(), '');
|
|
103
|
+
}
|
|
104
|
+
const post = await fetch(f.url, { method: 'POST', body: 'overwrite' });
|
|
105
|
+
assert.equal(post.status, 405);
|
|
106
|
+
assert.equal(post.headers.get('allow'), 'GET, HEAD');
|
|
107
|
+
const response = await fetch(f.url);
|
|
108
|
+
assert.deepEqual(Buffer.from(await response.arrayBuffer()), f.bytes);
|
|
109
|
+
});
|
|
110
|
+
|
|
111
|
+
test('integrity failures are not cached and a repaired asset can be retried', async (t) => {
|
|
112
|
+
const f = await fixture(t, true);
|
|
113
|
+
const bad = await fetch(f.url);
|
|
114
|
+
assert.equal(bad.status, 500);
|
|
115
|
+
assert.equal(await bad.text(), '');
|
|
116
|
+
assert.equal(bad.headers.get('cache-control'), 'no-store');
|
|
117
|
+
await writeFile(join(f.dir, 'clip.webm'), f.bytes);
|
|
118
|
+
const retry = await fetch(f.url);
|
|
119
|
+
assert.equal(retry.status, 200);
|
|
120
|
+
assert.deepEqual(Buffer.from(await retry.arrayBuffer()), f.bytes);
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
test('media files cannot be replaced by a symlink to another local file', async (t) => {
|
|
124
|
+
if (process.platform === 'win32') return t.skip('POSIX symlink protection');
|
|
125
|
+
const f = await fixture(t);
|
|
126
|
+
await rm(join(f.dir, 'clip.webm'));
|
|
127
|
+
await symlink(join(f.dir, 'data.json'), join(f.dir, 'clip.webm'));
|
|
128
|
+
const response = await fetch(f.url);
|
|
129
|
+
assert.equal(response.status, 500);
|
|
130
|
+
assert.equal(await response.text(), '');
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
test('route registration is optional and owned by the web service scope', () => {
|
|
134
|
+
registerMediaRoute({});
|
|
135
|
+
let activate,
|
|
136
|
+
route,
|
|
137
|
+
disposed = false;
|
|
138
|
+
registerMediaRoute({
|
|
139
|
+
inject(names, callback) {
|
|
140
|
+
assert.deepEqual(names, ['webServer']);
|
|
141
|
+
activate = callback;
|
|
142
|
+
},
|
|
143
|
+
});
|
|
144
|
+
assert.equal(route, undefined);
|
|
145
|
+
const disposers = [];
|
|
146
|
+
activate({
|
|
147
|
+
effect(fn) {
|
|
148
|
+
disposers.push(fn());
|
|
149
|
+
},
|
|
150
|
+
webServer: {
|
|
151
|
+
register(value) {
|
|
152
|
+
route = value;
|
|
153
|
+
return () => {
|
|
154
|
+
disposed = true;
|
|
155
|
+
};
|
|
156
|
+
},
|
|
157
|
+
},
|
|
158
|
+
});
|
|
159
|
+
assert.equal(route.path, MEDIA_ROUTE);
|
|
160
|
+
assert.equal(route.kind, 'prefix');
|
|
161
|
+
disposers[0]();
|
|
162
|
+
assert.equal(disposed, true);
|
|
163
|
+
});
|
|
164
|
+
|
|
165
|
+
test('media URLs respect application mount paths without carrying auth queries or external URLs', () => {
|
|
166
|
+
const relative = 'offwork-clock/media/abc/idle.webm';
|
|
167
|
+
const models = {
|
|
168
|
+
short: {
|
|
169
|
+
idle: relative,
|
|
170
|
+
external: 'https://elsewhere.test/movie',
|
|
171
|
+
traversal: 'offwork-clock/media/../../data.json',
|
|
172
|
+
},
|
|
173
|
+
};
|
|
174
|
+
assert.deepEqual(petClipUrls(models, 'https://example.test/app/?token=test#fragment'), {
|
|
175
|
+
short: { idle: 'https://example.test/app/' + relative },
|
|
176
|
+
});
|
|
177
|
+
assert.deepEqual(petClipUrls(models, 'file:///tmp/index.html'), {});
|
|
178
|
+
assert.deepEqual(petClipUrls(models, 'not a URL'), {});
|
|
179
|
+
});
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import test from 'node:test';
|
|
2
|
+
import assert from 'node:assert/strict';
|
|
3
|
+
import { initialPetReminder, nextPetReminder, dismissPetReminder } from '../pet-reminder.js';
|
|
4
|
+
const input = (patch = {}) => ({
|
|
5
|
+
ready: true,
|
|
6
|
+
eventId: 'shift:1',
|
|
7
|
+
phase: 'countdown',
|
|
8
|
+
visible: true,
|
|
9
|
+
available: true,
|
|
10
|
+
ownsPrompt: false,
|
|
11
|
+
...patch,
|
|
12
|
+
});
|
|
13
|
+
|
|
14
|
+
test('loading or refreshing an already-due reminder never opens a card', () => {
|
|
15
|
+
let state = nextPetReminder(initialPetReminder, input({ ready: false, eventId: null }));
|
|
16
|
+
state = nextPetReminder(state, input({ phase: 'due', ownsPrompt: true }));
|
|
17
|
+
assert.equal(state.open, false);
|
|
18
|
+
assert.equal(state.armed, false);
|
|
19
|
+
assert.equal(nextPetReminder(state, input({ phase: 'due', ownsPrompt: true })), state);
|
|
20
|
+
const explicitlyOpened = { ...state, open: true };
|
|
21
|
+
assert.equal(
|
|
22
|
+
nextPetReminder(explicitlyOpened, input({ phase: 'due', ownsPrompt: true })).open,
|
|
23
|
+
true,
|
|
24
|
+
);
|
|
25
|
+
});
|
|
26
|
+
|
|
27
|
+
test('a live countdown opens only for its lease owner and stays dismissed for the same due phase', () => {
|
|
28
|
+
let state = nextPetReminder(initialPetReminder, input());
|
|
29
|
+
assert.equal(state.armed, true);
|
|
30
|
+
state = nextPetReminder(state, input({ phase: 'due' }));
|
|
31
|
+
assert.equal(state.open, false);
|
|
32
|
+
assert.equal(state.armed, true);
|
|
33
|
+
state = nextPetReminder(state, input({ phase: 'due', ownsPrompt: true }));
|
|
34
|
+
assert.equal(state.open, true);
|
|
35
|
+
assert.equal(nextPetReminder(state, input({ phase: 'due', ownsPrompt: true })), state);
|
|
36
|
+
state = dismissPetReminder(state);
|
|
37
|
+
assert.equal(nextPetReminder(state, input({ phase: 'due', ownsPrompt: true })).open, false);
|
|
38
|
+
state = nextPetReminder(state, input({ eventId: 'shift:2' }));
|
|
39
|
+
assert.equal(
|
|
40
|
+
nextPetReminder(state, input({ eventId: 'shift:2', phase: 'due', ownsPrompt: true })).open,
|
|
41
|
+
true,
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test('open cards, background state and lease loss stop popups without delayed replay', () => {
|
|
46
|
+
for (const patch of [{ available: false }, { visible: false }, { ready: false }]) {
|
|
47
|
+
let state = nextPetReminder(initialPetReminder, input());
|
|
48
|
+
state = nextPetReminder(state, input({ phase: 'due', ownsPrompt: true, ...patch }));
|
|
49
|
+
assert.equal(state.open, false);
|
|
50
|
+
assert.equal(nextPetReminder(state, input({ phase: 'due', ownsPrompt: true })).open, false);
|
|
51
|
+
}
|
|
52
|
+
let state = nextPetReminder(initialPetReminder, input());
|
|
53
|
+
state = nextPetReminder(state, input({ phase: 'due', ownsPrompt: true }));
|
|
54
|
+
state = nextPetReminder(state, input({ phase: 'due', ownsPrompt: false }));
|
|
55
|
+
assert.equal(state.open, false);
|
|
56
|
+
assert.equal(nextPetReminder(state, input({ phase: 'due', ownsPrompt: true })).open, false);
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
test('quiet or unpaid-break phases can arm a reminder for work resumption without changing business state', () => {
|
|
60
|
+
for (const phase of ['quiet', 'break']) {
|
|
61
|
+
const previous = Object.freeze({ eventId: 'shift:1', armed: false, open: false });
|
|
62
|
+
const state = nextPetReminder(previous, input({ phase }));
|
|
63
|
+
assert.equal(state.armed, true);
|
|
64
|
+
assert.equal(nextPetReminder(state, input({ phase: 'due', ownsPrompt: true })).open, true);
|
|
65
|
+
assert.deepEqual(previous, { eventId: 'shift:1', armed: false, open: false });
|
|
66
|
+
}
|
|
67
|
+
for (const phase of ['off', 'waiting', 'paused', 'finished']) {
|
|
68
|
+
const state = nextPetReminder(nextPetReminder(initialPetReminder, input()), input({ phase }));
|
|
69
|
+
assert.equal(nextPetReminder(state, input({ phase: 'due', ownsPrompt: true })).open, false);
|
|
70
|
+
}
|
|
71
|
+
});
|