Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/update-check-notice.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@qawolf/cli": minor
---

The CLI now checks the npm registry for a newer published version while a command runs. After the command completes, the CLI shows an update notice one time for each new version. Human mode shows a styled note. Agent mode writes plain text to stderr. JSON mode writes a note diagnostic to stderr and does not change stdout. Set QAWOLF_NO_UPDATE_CHECK=1 to disable the check.
7 changes: 5 additions & 2 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,7 +43,8 @@ src/
│ ├── patternArgs.ts # CLI pattern argument parsing
│ ├── pluralize.ts # pluralize
│ ├── sleep.ts # sleep
│ └── types.ts # BrowserName, VideoMode, TraceMode, HarMode, TestCounts
│ ├── types.ts # BrowserName, VideoMode, TraceMode, HarMode, TestCounts
│ └── version.ts # isNewerVersion
├── shell/ # I/O executors — process spawning, UI, API clients
│ ├── appium/ # Android emulator + Appium server lifecycle
│ ├── commandContext.ts # CommandContext, CommandResult types
Expand All @@ -52,6 +53,7 @@ src/
│ ├── logger.ts # pino-based structured logger
│ ├── manifest/ # bundle manifest read/lookup
│ ├── npm.ts # resolveNpmCommand
│ ├── npmRegistry.ts # fetchLatestVersion (update-check registry lookup)
│ ├── platform/ # tRPC client, getIdentity, signed-URL/bundle download, team storage
│ ├── reporter/ # Reporter interface, console + JUnit + composite reporters
│ ├── resolveExport.ts # ESM export resolution
Expand All @@ -68,7 +70,8 @@ src/
│ ├── flows/ # expandPatterns, peekFlowMeta, flowsList, pull/
│ ├── init/ # init handler + templates
│ ├── install/ # installBrowsers, installBrowserList
│ └── runner/ # flowsRun, runWebFlow, runAndroidFlow, worker dispatch + pool
│ ├── runner/ # flowsRun, runWebFlow, runAndroidFlow, worker dispatch + pool
│ └── updateCheck/ # startUpdateCheck: new-version notice after commands
└── commands/ # Thin CLI glue — Commander registration + composite root
├── context.ts # withContext() Commander action wrapper
├── program.ts # createProgram() factory
Expand Down
38 changes: 31 additions & 7 deletions src/commands/context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,12 @@ import type {
CommandContext,
CommandResult,
} from "~/shell/commandContext.js";
import { fetchLatestVersion } from "~/shell/npmRegistry.js";
import {
startUpdateCheck,
type UpdateNotifier,
} from "~/domains/updateCheck/updateCheck.js";
import packageJson from "../../package.json" with { type: "json" };

type ContextAction = (ctx: CommandContext) => Promise<CommandResult>;
type AuthContextAction = (ctx: AuthCommandContext) => Promise<CommandResult>;
Expand All @@ -39,6 +45,7 @@ export function buildBaseContext(
ctx: CommandContext;
apiBaseUrl: string;
loggingSystem: LoggingSystem;
updateNotifier: UpdateNotifier;
} {
const env = process.env;
const flags = command.optsWithGlobals<GlobalFlags>();
Expand All @@ -61,12 +68,22 @@ export function buildBaseContext(
...(verboseWrite ? { verboseWrite } : {}),
});
const apiBaseUrl = resolveHostUrl(env);
const fs = makeDefaultFs();
const ui = createUI(outputMode, {
clack,
...(verboseTarget ? { verboseTarget } : {}),
});
const updateNotifier = startUpdateCheck({
env,
currentVersion: packageJson.version,
configDir: getConfigDir(),
fs,
fetchLatestVersion: () => fetchLatestVersion(packageJson.name),
renderNotice: (body, title) => ui.note(body, title),
});
return {
ctx: {
ui: createUI(outputMode, {
clack,
...(verboseTarget ? { verboseTarget } : {}),
}),
ui,
configDir: getConfigDir(),
outputMode,
isInteractive: isInteractive({
Expand All @@ -76,10 +93,11 @@ export function buildBaseContext(
apiBaseUrl,
signals,
log: (scope) => loggingSystem.createLogger(scope),
fs: makeDefaultFs(),
fs,
},
apiBaseUrl,
loggingSystem,
updateNotifier,
};
}

Expand All @@ -88,7 +106,10 @@ export function withContext(
fn: ContextAction,
): (opts: unknown, command: Command) => Promise<void> {
return async (_opts: unknown, command: Command): Promise<void> => {
const { ctx, loggingSystem } = buildBaseContext(command, signals);
const { ctx, loggingSystem, updateNotifier } = buildBaseContext(
command,
signals,
);
try {
const result = await fn(ctx);
if (result !== undefined) {
Expand All @@ -100,6 +121,7 @@ export function withContext(
process.exitCode = 1;
} finally {
loggingSystem.flush();
await updateNotifier.notifyIfOutdated();
}
};
}
Expand All @@ -113,7 +135,7 @@ export function withAuthContext(
} = {},
): (opts: unknown, command: Command) => Promise<void> {
return async (_opts: unknown, command: Command): Promise<void> => {
const { ctx, apiBaseUrl, loggingSystem } = buildBaseContext(
const { ctx, apiBaseUrl, loggingSystem, updateNotifier } = buildBaseContext(
command,
signals,
);
Expand All @@ -127,6 +149,7 @@ export function withAuthContext(
});
if (resolved === undefined) {
loggingSystem.flush();
await updateNotifier.notifyIfOutdated();
return;
}

Expand All @@ -150,6 +173,7 @@ export function withAuthContext(
process.exitCode = 1;
} finally {
loggingSystem.flush();
await updateNotifier.notifyIfOutdated();
}
};
}
1 change: 1 addition & 0 deletions src/core/messages/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ export { initMessages } from "./init.js";
export { installMessages } from "./install.js";
export { runnerMessages } from "./runner.js";
export { packageLoadFailed } from "./toolNotFound.js";
export { updateCheckMessages } from "./updateCheck.js";
5 changes: 5 additions & 0 deletions src/core/messages/updateCheck.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
export const updateCheckMessages = {
title: "Update available",
body: (current: string, latest: string): string =>
`@qawolf/cli ${current} → ${latest}\nRun \`npm install -g @qawolf/cli\` to update.`,
};
29 changes: 29 additions & 0 deletions src/core/version.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { describe, expect, it } from "bun:test";

import { isNewerVersion } from "./version.js";

describe("isNewerVersion", () => {
it("detects newer patch, minor, and major releases", () => {
expect(isNewerVersion("1.3.2", "1.3.3")).toBe(true);
expect(isNewerVersion("1.3.2", "1.4.0")).toBe(true);
expect(isNewerVersion("1.3.2", "2.0.0")).toBe(true);
});

it("compares numerically, not lexicographically", () => {
expect(isNewerVersion("1.9.0", "1.10.0")).toBe(true);
expect(isNewerVersion("1.10.0", "1.9.0")).toBe(false);
});

it("returns false for equal or older versions", () => {
expect(isNewerVersion("1.3.2", "1.3.2")).toBe(false);
expect(isNewerVersion("1.3.2", "1.3.1")).toBe(false);
expect(isNewerVersion("2.0.0", "1.9.9")).toBe(false);
});

it("returns false when either version is not a plain release", () => {
expect(isNewerVersion("1.3.2", "1.4.0-beta.1")).toBe(false);
expect(isNewerVersion("1.3.2-beta.1", "1.4.0")).toBe(false);
expect(isNewerVersion("1.3.2", "not-a-version")).toBe(false);
expect(isNewerVersion("", "1.4.0")).toBe(false);
});
});
25 changes: 25 additions & 0 deletions src/core/version.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
type Release = { major: number; minor: number; patch: number };

function parseRelease(version: string): Release | undefined {
const match = /^(\d+)\.(\d+)\.(\d+)$/.exec(version);
if (!match) return undefined;
return {
major: Number(match[1]),
minor: Number(match[2]),
patch: Number(match[3]),
};
}

/**
* Only plain `major.minor.patch` versions compare. Prereleases and
* unparseable input return false, so callers stay silent rather than
* mis-notify.
*/
export function isNewerVersion(current: string, latest: string): boolean {
const cur = parseRelease(current);
const next = parseRelease(latest);
if (cur === undefined || next === undefined) return false;
if (next.major !== cur.major) return next.major > cur.major;
if (next.minor !== cur.minor) return next.minor > cur.minor;
return next.patch > cur.patch;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
}
135 changes: 135 additions & 0 deletions src/domains/updateCheck/updateCheck.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,135 @@
import { describe, expect, it } from "bun:test";

import type { Fs } from "~/shell/fs.js";
import { makeMemoryFs } from "~/shell/fs.testUtils.js";
import { startUpdateCheck } from "./updateCheck.js";

// Let the fetch's .then chain run before reading the settled value.
const settle = () => new Promise((resolve) => setTimeout(resolve, 0));

function makeNotifier(overrides: {
env?: Record<string, string | undefined>;
currentVersion?: string;
fs?: Fs;
fetchLatestVersion?: () => Promise<string | undefined>;
renderNotice?: () => void;
}) {
const written: { body: string; title: string }[] = [];
let fetchCalls = 0;
const notifier = startUpdateCheck({
env: overrides.env ?? {},
currentVersion: overrides.currentVersion ?? "1.0.0",
configDir: "/config",
fs: overrides.fs ?? makeMemoryFs(),
fetchLatestVersion: () => {
fetchCalls += 1;
return (
overrides.fetchLatestVersion ?? (() => Promise.resolve("2.0.0"))
)();
},
renderNotice: (body, title) => {
written.push({ body, title });
overrides.renderNotice?.();
},
});
return { notifier, written, fetchCalls: () => fetchCalls };
}

describe("startUpdateCheck", () => {
it("announces a newer version after the fetch settles", async () => {
const { notifier, written } = makeNotifier({});
await settle();
await notifier.notifyIfOutdated();
expect(written).toHaveLength(1);
expect(written[0]?.body).toContain("1.0.0 → 2.0.0");
expect(written[0]?.title).toBe("Update available");
});

it("stays silent while the fetch is still pending", async () => {
const { notifier, written } = makeNotifier({
fetchLatestVersion: () => new Promise(() => {}),
});
await notifier.notifyIfOutdated();
expect(written).toHaveLength(0);
});

it("stays silent when the published version is not newer", async () => {
const { notifier, written } = makeNotifier({
currentVersion: "2.0.0",
fetchLatestVersion: () => Promise.resolve("2.0.0"),
});
await settle();
await notifier.notifyIfOutdated();
expect(written).toHaveLength(0);
});

it("announces each version at most once across runs", async () => {
const fs = makeMemoryFs();
const first = makeNotifier({ fs });
await settle();
await first.notifier.notifyIfOutdated();
expect(first.written).toHaveLength(1);

const second = makeNotifier({ fs });
await settle();
await second.notifier.notifyIfOutdated();
expect(second.written).toHaveLength(0);
});

it("announces again when an even newer version ships", async () => {
const fs = makeMemoryFs();
const first = makeNotifier({ fs });
await settle();
await first.notifier.notifyIfOutdated();

const second = makeNotifier({
fs,
fetchLatestVersion: () => Promise.resolve("3.0.0"),
});
await settle();
await second.notifier.notifyIfOutdated();
expect(second.written).toHaveLength(1);
expect(second.written[0]?.body).toContain("1.0.0 → 3.0.0");
});

it("does not fetch when QAWOLF_NO_UPDATE_CHECK is set", async () => {
const { notifier, written, fetchCalls } = makeNotifier({
env: { QAWOLF_NO_UPDATE_CHECK: "1" },
});
await settle();
await notifier.notifyIfOutdated();
expect(fetchCalls()).toBe(0);
expect(written).toHaveLength(0);
});

it("still announces (and never throws) when the state file is unwritable", async () => {
const fs: Fs = {
...makeMemoryFs(),
readFile: () => Promise.reject(new Error("boom")),
mkdir: () => Promise.reject(new Error("boom")),
writeFile: () => Promise.reject(new Error("boom")),
};
const { notifier, written } = makeNotifier({ fs });
await settle();
await notifier.notifyIfOutdated();
expect(written).toHaveLength(1);
});

it("does not throw when rendering fails, and retries next run", async () => {
const fs = makeMemoryFs();
const first = makeNotifier({
fs,
renderNotice: () => {
throw new Error("EPIPE");
},
});
await settle();
await first.notifier.notifyIfOutdated();

// A failed render must not record the version as announced.
const second = makeNotifier({ fs });
await settle();
await second.notifier.notifyIfOutdated();
expect(second.written).toHaveLength(1);
});
});
Loading
Loading