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
30 changes: 29 additions & 1 deletion packages/timer/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -27,9 +27,37 @@ Start a timer for 1 minute and 42 seconds
Get help

```sh
> search --help
> timer --help
```

## Notifications

When the timer is done it raises a desktop notification. On macOS this is a
dialog carrying the timer's own icon, which stays on screen until you dismiss
it — a banner auto-dismisses after a few seconds, so a timer that fires while
you are away from the keyboard would leave nothing behind. The dialog is
released detached, so the timer itself exits immediately and never blocks
whatever you chained behind it.

Prefer the passive banner:

```sh
> timer 1m42s --banner
```

Turn notifications off entirely:

```sh
> timer 1m42s --no-notification
```

`--banner` is macOS-only; Linux (`notify-send`) and Windows (toast) always use
their native banner.

Be aware that macOS silently suppresses banners while a Focus mode is on or
while you are sharing your screen — `--banner` will appear to do nothing. The
dialog is an ordinary window, so it is not affected by either.

## License

MIT © Arno Versini
Binary file added packages/timer/assets/hourglass.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
30 changes: 30 additions & 0 deletions packages/timer/assets/hourglass.svg
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
1 change: 1 addition & 0 deletions packages/timer/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"exports": "./dist/timer.js",
"bin": "dist/timer.js",
"files": [
"assets",
"dist",
"README.md"
],
Expand Down
117 changes: 109 additions & 8 deletions packages/timer/src/__tests__/notify.test.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { execFile } from "node:child_process";
import { execFile, spawn } from "node:child_process";
import { vi } from "vitest";
import { buildNotifyCommand, type Notification, notify } from "../notify.js";

Expand Down Expand Up @@ -34,10 +34,20 @@ vi.mock("node:child_process", () => {
);
return promise;
};
return { execFile };
return { execFile, spawn: vi.fn() };
});

const mockedExecFile = vi.mocked(execFile);
const mockedSpawn = vi.mocked(spawn);

/**
* Mimics the subset of ChildProcess that the detached path touches.
*/
const spawnReturnsChild = () => {
const child = { on: vi.fn(), unref: vi.fn() };
mockedSpawn.mockReturnValue(child as unknown as ReturnType<typeof spawn>);
return child;
};

type ExecFileCallback = (error: NodeJS.ErrnoException | null) => void;

Expand Down Expand Up @@ -91,10 +101,46 @@ const notification = {
title: "Timer Notification",
};

const bannerNotification = { ...notification, banner: true };

describe("when building the platform-native notification command", () => {
it("should use osascript on macOS", async () => {
expect(buildNotifyCommand("darwin", notification)).toStrictEqual({
/**
* The dialog is the macOS default because a banner cannot carry the timer's
* own icon and disappears on its own, which loses the alarm entirely when
* nobody is watching the screen.
*/
it("should default to an alert dialog on macOS", async () => {
const { args, command, detached } = buildNotifyCommand(
"darwin",
notification,
);

expect(command).toBe("osascript");
expect(detached).toBe(true);
expect(args.at(-1)).toContain('display dialog "Time\'s up!"');
expect(args.at(-1)).toContain('with title "Timer Notification"');
expect(args.at(-1)).toContain("hourglass.png");
expect(args.at(-1)).toContain('buttons {"OK"} default button "OK"');
});

it("should play the sound alongside the alert dialog on macOS", async () => {
const { args } = buildNotifyCommand("darwin", notification);

expect(args[0]).toBe("-e");
expect(args[1]).toContain("afplay /System/Library/Sounds/Funk.aiff");
});

it("should omit the sound script when the alert has no sound", async () => {
const { args } = buildNotifyCommand("darwin", { message: "a", title: "b" });

expect(args).toHaveLength(2);
expect(args[1]).not.toContain("afplay");
});

it("should use a banner on macOS when one is asked for", async () => {
expect(buildNotifyCommand("darwin", bannerNotification)).toStrictEqual({
command: "osascript",
detached: false,
args: [
"-e",
'display notification "Time\'s up!" with title "Timer Notification" sound name "Funk"',
Expand All @@ -104,15 +150,17 @@ describe("when building the platform-native notification command", () => {

it("should omit the sound on macOS when none is requested", async () => {
expect(
buildNotifyCommand("darwin", { message: "a", title: "b" }),
buildNotifyCommand("darwin", { banner: true, message: "a", title: "b" }),
).toStrictEqual({
command: "osascript",
detached: false,
args: ["-e", 'display notification "a" with title "b"'],
});
});

it("should escape quotes and backslashes in the AppleScript literal", async () => {
const { args } = buildNotifyCommand("darwin", {
banner: true,
message: 'say "hi" \\ bye',
title: 'a "quoted" title',
});
Expand All @@ -121,9 +169,23 @@ describe("when building the platform-native notification command", () => {
);
});

it("should escape quotes and backslashes in the alert dialog literal", async () => {
const { args } = buildNotifyCommand("darwin", {
message: 'say "hi" \\ bye',
title: 'a "quoted" title',
});
expect(args.at(-1)).toContain('display dialog "say \\"hi\\" \\\\ bye"');
expect(args.at(-1)).toContain('with title "a \\"quoted\\" title"');
});

/**
* The alert is macOS-only: the other two platforms get their native banner
* whether or not one was asked for.
*/
it("should use notify-send on Linux", async () => {
expect(buildNotifyCommand("linux", notification)).toStrictEqual({
command: "notify-send",
detached: false,
args: ["Timer Notification", "Time's up!"],
});
});
Expand Down Expand Up @@ -153,11 +215,12 @@ describe("when building the platform-native notification command", () => {
describe("when displaying a notification", () => {
beforeEach(() => {
mockedExecFile.mockReset();
mockedSpawn.mockReset();
});

it("should resolve to true when the notifier runs", async () => {
spawnSucceeds();
await expect(notify(notification, "darwin")).resolves.toBe(true);
await expect(notify(bannerNotification, "darwin")).resolves.toBe(true);
expect(mockedExecFile).toHaveBeenCalledTimes(1);
});

Expand All @@ -169,7 +232,7 @@ describe("when displaying a notification", () => {
*/
it("should resolve to false when the notifier cannot run on this CPU", async () => {
spawnThrowsBadArch();
await expect(notify(notification, "darwin")).resolves.toBe(false);
await expect(notify(bannerNotification, "darwin")).resolves.toBe(false);
});

it("should resolve to false when the notifier is not installed", async () => {
Expand All @@ -184,7 +247,7 @@ describe("when displaying a notification", () => {
*/
it("should bound the spawn so a hung notifier cannot keep the CLI alive", async () => {
spawnSucceeds();
await notify(notification, "darwin");
await notify(bannerNotification, "darwin");
expect(mockedExecFile).toHaveBeenCalledWith(
"osascript",
expect.any(Array),
Expand All @@ -193,6 +256,44 @@ describe("when displaying a notification", () => {
);
});

/**
* The alert waits for a human, so awaiting it would hold the timer open until
* the dialog is clicked and stall anything chained behind it.
*/
it("should detach the alert instead of waiting for it to be dismissed", async () => {
const child = spawnReturnsChild();

await expect(notify(notification, "darwin")).resolves.toBe(true);
expect(mockedExecFile).not.toHaveBeenCalled();
expect(mockedSpawn).toHaveBeenCalledWith(
"osascript",
expect.any(Array),
expect.objectContaining({ detached: true, stdio: "ignore" }),
);
expect(child.unref).toHaveBeenCalledTimes(1);
});

/**
* An unhandled "error" event on a child process is thrown, so a notifier that
* cannot spawn at all would otherwise take the timer down with it.
*/
it("should swallow a spawn failure on the detached alert", async () => {
const child = spawnReturnsChild();

await notify(notification, "darwin");
expect(child.on).toHaveBeenCalledWith("error", expect.any(Function));

const handler = child.on.mock.calls[0][1] as () => void;
expect(handler).not.toThrow();
});

it("should resolve to false when the alert cannot be spawned at all", async () => {
mockedSpawn.mockImplementation(() => {
throw new Error("spawn EACCES");
});
await expect(notify(notification, "darwin")).resolves.toBe(false);
});

it("should resolve to false when a malformed notification is passed", async () => {
await expect(
notify(
Expand Down
1 change: 1 addition & 0 deletions packages/timer/src/defaults.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
/* v8 ignore start */
export const defaultFlags = {
banner: false,
boring: false,
notification: true,
};
Expand Down
Loading