Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,8 +16,16 @@ jobs:
node-version: '22'
cache: npm

# --ignore-scripts blocks dependency lifecycle scripts (a supply-chain
# foothold: any transitive dep can run arbitrary code at install time).
# Three deps ship install scripts — esbuild, ffmpeg-for-homebridge and
# protobufjs — and all three only fetch or build binaries that this job
# does not use: it type-checks and runs the hermetic tests, which touch
# neither ffmpeg nor tsx. Nothing is rebuilt on purpose. If a future test
# needs a real ffmpeg, rebuild that one package explicitly rather than
# dropping this flag.
- name: Install dependencies
run: npm ci
run: npm ci --ignore-scripts

- name: Type-check / build
run: npm run build
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@
"record": "tsx src/cli.ts record",
"list": "tsx src/cli.ts list",
"verify": "tsx src/verify.ts",
"test": "npm run build && node test/logic.test.mjs",
"test": "npm run build && node --test test/*.test.mjs",
"typecheck": "tsc -p tsconfig.json --noEmit"
},
"dependencies": {
Expand Down
245 changes: 168 additions & 77 deletions test/logic.test.mjs
Original file line number Diff line number Diff line change
@@ -1,86 +1,177 @@
// Hermetic logic tests — no Ring account or network required.
// Run with: npm test (builds to dist/ first, then runs this against dist).
import { describe, test } from 'node:test';
import assert from 'node:assert/strict';
import { clipFilename, slugify, pruneOldClips, ensureDir } from '../dist/files.js';
import { watchCamera } from '../dist/events.js';
import { mkdtempSync, writeFileSync, utimesSync, readdirSync } from 'node:fs';
import { tmpdir } from 'node:os';
import { join } from 'node:path';

const tick = () => new Promise((r) => setTimeout(r, 5));
let pass = 0, fail = 0;
const ok = (c, m) => (c ? (pass++, console.log(' PASS', m)) : (fail++, console.log(' FAIL', m)));

console.log('files: naming + retention');
{
const name = clipFilename('Front Door!', new Date('2026-06-17T14:03:22.500Z'));
ok(/^Front-Door_2026-06-17T14-03-22-500Z\.mp4$/.test(name), 'filename keeps ms, colons stripped (no same-second collision)');
ok(slugify(' Backyard / Garage ') === 'Backyard-Garage', 'slugify sanitizes unsafe chars');
ok(slugify('***') === 'camera', 'slugify falls back to "camera" for empty result');

const dir = mkdtempSync(join(tmpdir(), 'ring-ret-'));
ensureDir(dir);
const old = join(dir, 'Cam_old.mp4'); writeFileSync(old, 'x');
writeFileSync(join(dir, 'Cam_fresh.mp4'), 'x');
writeFileSync(join(dir, 'notes.txt'), 'x');
const tenDaysAgo = Date.now() / 1000 - 10 * 86400;
utimesSync(old, tenDaysAgo, tenDaysAgo);
const deleted = pruneOldClips(dir, 7, new Date());
const left = readdirSync(dir).sort();
ok(deleted.length === 1 && deleted[0].endsWith('Cam_old.mp4'), 'pruned exactly the >7d clip');
ok(left.includes('Cam_fresh.mp4'), 'kept the fresh clip');
ok(left.includes('notes.txt'), 'left non-mp4 file untouched');
ok(pruneOldClips(dir, null, new Date()).length === 0, 'retention=null is a no-op');
}

console.log('events: motion/ding trigger state machine');
function obs() {
const subs = [];
return { subscribe(fn) { subs.push(fn); return { unsubscribe() {} }; }, next(v) { subs.forEach((f) => f(v)); } };
}
const fakeCamera = (isDoorbot) => ({ name: 'TestCam', id: 42, isDoorbot, onMotionDetected: obs(), onDoorbellPressed: obs() });
const cfg = (over = {}) => ({ clipLengthSeconds: 10, recordOnMotion: true, recordOnDing: true, motionCooldownSeconds: 0, ...over });
function spy() {
const calls = []; let resolveLast;
const fn = (camera, c, seconds) => { calls.push({ seconds }); return new Promise((res) => { resolveLast = () => res({ camera: camera.name, path: 'x', bytes: 1, seconds }); }); };
return { fn, calls, finish: () => resolveLast?.() };
}

{
const cam = fakeCamera(false); const s = spy();
watchCamera(cam, cfg(), s.fn);
cam.onMotionDetected.next(false); await tick();
ok(s.calls.length === 0, 'motion=false does not trigger');
cam.onMotionDetected.next(true); await tick();
ok(s.calls.length === 1 && s.calls[0].seconds === 10, 'rising edge (false->true) records one clip');
cam.onMotionDetected.next(true); await tick();
ok(s.calls.length === 1, 'sustained true (no new rising edge) does not re-trigger');
s.finish(); await tick();
cam.onMotionDetected.next(false); await tick();
cam.onMotionDetected.next(true); await tick();
ok(s.calls.length === 2, 'a new rising edge after motion ends triggers again');
}
{
const cam = fakeCamera(false); const s = spy();
watchCamera(cam, cfg({ motionCooldownSeconds: 3600 }), s.fn);
cam.onMotionDetected.next(true); await tick(); s.finish(); await tick();
cam.onMotionDetected.next(false); await tick();
cam.onMotionDetected.next(true); await tick();
ok(s.calls.length === 1, 'new rising edge within cooldown is skipped');
}
{
const cam = fakeCamera(true); const s = spy();
watchCamera(cam, cfg({ recordOnMotion: false }), s.fn);
cam.onMotionDetected.next(true); await tick();
ok(s.calls.length === 0, 'motion ignored when recordOnMotion=false');
cam.onDoorbellPressed.next({}); await tick();
ok(s.calls.length === 1, 'doorbell ding triggers a recording');
}
{
const cam = fakeCamera(false); const s = spy();
watchCamera(cam, cfg(), s.fn);
cam.onDoorbellPressed.next({}); await tick();
ok(s.calls.length === 0, 'ding on non-doorbot does nothing');
}

console.log(`\n${fail === 0 ? 'ALL PASS' : 'FAILURES'}: ${pass} passed, ${fail} failed`);
process.exit(fail === 0 ? 0 : 1);

describe('files: naming', () => {
test('filename keeps ms, colons stripped (no same-second collision)', () => {
const name = clipFilename('Front Door!', new Date('2026-06-17T14:03:22.500Z'));
assert.match(name, /^Front-Door_2026-06-17T14-03-22-500Z\.mp4$/);
});

test('slugify sanitizes unsafe chars', () => {
assert.equal(slugify(' Backyard / Garage '), 'Backyard-Garage');
});

test('slugify falls back to "camera" for empty result', () => {
assert.equal(slugify('***'), 'camera');
});
});

describe('files: retention', () => {
/** Fresh dir per test: one >7d clip, one fresh clip, one non-mp4 file. */
const setup = () => {
const dir = mkdtempSync(join(tmpdir(), 'ring-ret-'));
ensureDir(dir);
const old = join(dir, 'Cam_old.mp4');
writeFileSync(old, 'x');
writeFileSync(join(dir, 'Cam_fresh.mp4'), 'x');
writeFileSync(join(dir, 'notes.txt'), 'x');
const tenDaysAgo = Date.now() / 1000 - 10 * 86400;
utimesSync(old, tenDaysAgo, tenDaysAgo);
return dir;
};

test('prunes exactly the >7d clip', () => {
const deleted = pruneOldClips(setup(), 7, new Date());
assert.equal(deleted.length, 1);
assert.ok(deleted[0].endsWith('Cam_old.mp4'), 'deleted the old clip');
});

test('keeps the fresh clip', () => {
const dir = setup();
pruneOldClips(dir, 7, new Date());
assert.ok(readdirSync(dir).includes('Cam_fresh.mp4'));
});

test('leaves non-mp4 files untouched', () => {
const dir = setup();
pruneOldClips(dir, 7, new Date());
assert.ok(readdirSync(dir).includes('notes.txt'));
});

test('retention=null is a no-op', () => {
const dir = setup();
assert.equal(pruneOldClips(dir, null, new Date()).length, 0);
assert.equal(readdirSync(dir).length, 3, 'nothing was deleted');
});
});

describe('events: motion/ding trigger state machine', () => {
/** Minimal stand-in for an rxjs Observable — just enough for watchCamera. */
function obs() {
const subs = [];
return {
subscribe(fn) {
subs.push(fn);
return { unsubscribe() {} };
},
next(v) {
subs.forEach((f) => f(v));
},
};
}

const fakeCamera = (isDoorbot) => ({
name: 'TestCam',
id: 42,
isDoorbot,
onMotionDetected: obs(),
onDoorbellPressed: obs(),
});

const cfg = (over = {}) => ({
clipLengthSeconds: 10,
recordOnMotion: true,
recordOnDing: true,
motionCooldownSeconds: 0,
...over,
});

/** Stands in for recordClip: records the calls and lets the test resolve them. */
function spy() {
const calls = [];
let resolveLast;
const fn = (camera, c, seconds) => {
calls.push({ seconds });
return new Promise((res) => {
resolveLast = () => res({ camera: camera.name, path: 'x', bytes: 1, seconds });
});
};
return { fn, calls, finish: () => resolveLast?.() };
}

test('records once per false->true transition, not while motion stays true', async () => {
const cam = fakeCamera(false);
const s = spy();
watchCamera(cam, cfg(), s.fn);

cam.onMotionDetected.next(false);
await tick();
assert.equal(s.calls.length, 0, 'motion=false does not trigger');

cam.onMotionDetected.next(true);
await tick();
assert.equal(s.calls.length, 1, 'rising edge (false->true) records one clip');
assert.equal(s.calls[0].seconds, 10, 'used the configured clip length');

cam.onMotionDetected.next(true);
await tick();
assert.equal(s.calls.length, 1, 'sustained true (no new rising edge) does not re-trigger');

s.finish();
await tick();
cam.onMotionDetected.next(false);
await tick();
cam.onMotionDetected.next(true);
await tick();
assert.equal(s.calls.length, 2, 'a new rising edge after motion ends triggers again');
});

test('skips a new rising edge that lands inside the cooldown window', async () => {
const cam = fakeCamera(false);
const s = spy();
watchCamera(cam, cfg({ motionCooldownSeconds: 3600 }), s.fn);

cam.onMotionDetected.next(true);
await tick();
s.finish();
await tick();
cam.onMotionDetected.next(false);
await tick();
cam.onMotionDetected.next(true);
await tick();
assert.equal(s.calls.length, 1);
});

test('ignores motion when recordOnMotion=false but still records a ding', async () => {
const cam = fakeCamera(true);
const s = spy();
watchCamera(cam, cfg({ recordOnMotion: false }), s.fn);

cam.onMotionDetected.next(true);
await tick();
assert.equal(s.calls.length, 0, 'motion ignored when recordOnMotion=false');

cam.onDoorbellPressed.next({});
await tick();
assert.equal(s.calls.length, 1, 'doorbell ding triggers a recording');
});

test('ignores a ding on a camera that is not a doorbell', async () => {
const cam = fakeCamera(false);
const s = spy();
watchCamera(cam, cfg(), s.fn);

cam.onDoorbellPressed.next({});
await tick();
assert.equal(s.calls.length, 0);
});
});
Loading