From 86c9c49efad94cc68e107ae413133ceac1c1919b Mon Sep 17 00:00:00 2001 From: Charan Rathore <180254320+charan-rathore@users.noreply.github.com> Date: Thu, 8 Oct 2026 14:41:02 +0530 Subject: [PATCH 1/2] fix(monitors): do not truncate malformed price tokens in price_below matchesPrice consumed a numeric prefix of malformed amounts: $1,00 and USD1,00 became 1, and $9.999 became 9.99, so a threshold of 10 matched and sent a notification for a page that never showed a real price under it. Reject a match when the next character continues the numeric token (digit or comma, or a decimal dot followed by a digit); slash and sentence punctuation stay separators. Regression cases run the real browser-fixture, API and worker outcome path. Signed-off-by: Charan Rathore <180254320+charan-rathore@users.noreply.github.com> --- apps/server/src/engine/service.ts | 4 +- tests/monitor-price-token-boundaries.test.ts | 48 ++++++++++++++++++++ 2 files changed, 51 insertions(+), 1 deletion(-) create mode 100644 tests/monitor-price-token-boundaries.test.ts diff --git a/apps/server/src/engine/service.ts b/apps/server/src/engine/service.ts index 0e071ba87..acf5834ce 100644 --- a/apps/server/src/engine/service.ts +++ b/apps/server/src/engine/service.ts @@ -1193,7 +1193,9 @@ export class AgentService { }; } private matchesPrice(text: string, threshold: number) { - const matches = [...text.matchAll(/(?:\$|USD)\s*(\d+(?:,\d{3})*(?:\.\d{1,2})?)/g)]; + const matches = [ + ...text.matchAll(/(?:\$|USD)\s*(\d+(?:,\d{3})*(?:\.\d{1,2})?)(?![\d,]|\.\d)/g), + ]; return matches.some((m) => Number(m[1].replace(/,/g, "")) < threshold); } } diff --git a/tests/monitor-price-token-boundaries.test.ts b/tests/monitor-price-token-boundaries.test.ts new file mode 100644 index 000000000..f88b94a47 --- /dev/null +++ b/tests/monitor-price-token-boundaries.test.ts @@ -0,0 +1,48 @@ +import assert from "node:assert/strict"; +import test from "node:test"; +import { createApp } from "../apps/server/src/app.ts"; +import type { AgentNotification } from "../packages/domain/src/agent.ts"; +import { browserFixture } from "./helpers/browser.ts"; + +for (const [text, expected] of [ + ["Price: $1,00", false], + ["Price: $9.999", false], + ["Price: USD 1,00", false], + ["Price: $1,000,00", false], + ["Price: $9.99/month", true], + ["Price: $1,000.00", false], + ["Price: $9", true], +] as const) { + test(`price watch does not truncate malformed numeric tokens: ${JSON.stringify(text)}`, async (t) => { + const url = "https://example.com/product"; + const fixture = await browserFixture(t, (path, body) => ({ + data: path.endsWith("/read") + ? { url, title: "Product", text, truncated: false } + : { + id: body.id, + title: "Product", + url, + status: "active", + updatedAt: new Date().toISOString(), + }, + })); + const server = await createApp(fixture.db, fixture.config); + t.after(() => server.agent.stop()); + const owner = "price-spacing-user"; + const monitor = await server.agent.createMonitor(owner, { + title: "Price watch", + url, + condition: "price_below", + value: "10", + }); + await server.agent.worker.tick(); + const task = await server.agent.getTask(owner, monitor.taskId); + assert.equal(task.status, "scheduled"); + assert.equal(task.state.matched, expected); + const notifications = await fixture.db.list(owner, "notifications"); + assert.equal( + notifications.filter((entry) => entry.taskId === task.id).length, + expected ? 1 : 0, + ); + }); +} From b125de370fdeaf92006319c0ef1800e8c2305e7f Mon Sep 17 00:00:00 2001 From: Charan Rathore <180254320+charan-rathore@users.noreply.github.com> Date: Thu, 8 Oct 2026 21:30:06 +0530 Subject: [PATCH 2/2] fix(monitors): keep sentence commas and reject magnitude suffixes in price_below The malformed-token lookahead also rejected an ordinary comma after a real price, so "Only $5," or "Price: $9, limited time" never matched and a watch could not fire. A price with a magnitude letter still truncated: $9M and $9k read as 9. Reject only a digit, a comma followed by a digit, a decimal dot followed by a digit, or a trailing letter after the amount. Extract matchesPrice into apps/server/src/engine/price.ts and cover it with fast table cases (comma sentences, lists, magnitude suffixes, truncated groups); keep one browser-fixture case for the worker wiring. Signed-off-by: Charan Rathore <180254320+charan-rathore@users.noreply.github.com> --- apps/server/src/engine/price.ts | 10 +++ apps/server/src/engine/service.ts | 9 +- tests/monitor-price-token-boundaries.test.ts | 90 +++++++++++--------- 3 files changed, 63 insertions(+), 46 deletions(-) create mode 100644 apps/server/src/engine/price.ts diff --git a/apps/server/src/engine/price.ts b/apps/server/src/engine/price.ts new file mode 100644 index 000000000..9c888471d --- /dev/null +++ b/apps/server/src/engine/price.ts @@ -0,0 +1,10 @@ +// A match is rejected when the next character continues the numeric token +// (digit, a comma followed by a digit, a decimal dot followed by a digit) or +// attaches a magnitude/unit letter ($9M, $9k). A sentence comma, slash or +// other punctuation still ends the price. +const PRICE_PATTERN = /(?:\$|USD)\s*(\d+(?:,\d{3})*(?:\.\d{1,2})?)(?!\d|[A-Za-z]|,\d|\.\d)/g; + +export function matchesPrice(text: string, threshold: number): boolean { + const matches = [...text.matchAll(PRICE_PATTERN)]; + return matches.some((m) => Number(m[1].replace(/,/g, "")) < threshold); +} diff --git a/apps/server/src/engine/service.ts b/apps/server/src/engine/service.ts index acf5834ce..8eb2ab446 100644 --- a/apps/server/src/engine/service.ts +++ b/apps/server/src/engine/service.ts @@ -42,6 +42,7 @@ import { pageLines, withoutRelativeTimes, } from "./page-diff.ts"; +import { matchesPrice } from "./price.ts"; import { LostLeaseError, type TaskContext, TaskWorker } from "./worker.ts"; const hash = (text: string) => createHash("sha256").update(text).digest("hex"); @@ -1094,7 +1095,7 @@ export class AgentService { ? Boolean(previousHash && previousHash !== currentHash) : monitor.condition === "contains" ? text.toLowerCase().includes(monitor.value.toLowerCase()) - : this.matchesPrice(text, Number(monitor.value)); + : matchesPrice(text, Number(monitor.value)); const previouslyMatched = Boolean(task.state.matched); // For change watches, keep the page lines and a relative-time-free fingerprint of the whole text. const change = monitor.condition === "change"; @@ -1192,10 +1193,4 @@ export class AgentService { plan: task.plan.map((s) => ({ ...s, status: "succeeded" })), }; } - private matchesPrice(text: string, threshold: number) { - const matches = [ - ...text.matchAll(/(?:\$|USD)\s*(\d+(?:,\d{3})*(?:\.\d{1,2})?)(?![\d,]|\.\d)/g), - ]; - return matches.some((m) => Number(m[1].replace(/,/g, "")) < threshold); - } } diff --git a/tests/monitor-price-token-boundaries.test.ts b/tests/monitor-price-token-boundaries.test.ts index f88b94a47..e6ae93ee1 100644 --- a/tests/monitor-price-token-boundaries.test.ts +++ b/tests/monitor-price-token-boundaries.test.ts @@ -1,48 +1,60 @@ import assert from "node:assert/strict"; import test from "node:test"; import { createApp } from "../apps/server/src/app.ts"; +import { matchesPrice } from "../apps/server/src/engine/price.ts"; import type { AgentNotification } from "../packages/domain/src/agent.ts"; import { browserFixture } from "./helpers/browser.ts"; -for (const [text, expected] of [ - ["Price: $1,00", false], - ["Price: $9.999", false], - ["Price: USD 1,00", false], - ["Price: $1,000,00", false], - ["Price: $9.99/month", true], - ["Price: $1,000.00", false], - ["Price: $9", true], +const THRESHOLD = 10; + +for (const [text, expected, why] of [ + ["Price: $1,00", false, "truncated thousands group is not a price"], + ["Price: $1,0", false, "truncated thousands group is not a price"], + ["Price: $9.999", false, "a third decimal digit is not a price"], + ["Price: USD 1,00", false, "truncated thousands group is not a price"], + ["Price: $9M", false, "a magnitude suffix is not a bare price"], + ["Price: $9k", false, "a magnitude suffix is not a bare price"], + ["Price: $1,000,00", false, "a second thousands comma is not a price"], + ["Price: $1,000", false, "1000 is not under the threshold"], + ["Price: $1,000.00", false, "1000 is not under the threshold"], + ["Only $5,", true, "a sentence comma still ends the price"], + ["Price: $9, limited time", true, "a sentence comma still ends the price"], + ["$5, $20", true, "a comma-separated list keeps both prices"], + ["Now $9, was $20", true, "the lower price is still found"], + ["Price: $9.99/month", true, "a slash still ends the price"], + ["Price: $9", true, "a plain price under the threshold matches"], ] as const) { - test(`price watch does not truncate malformed numeric tokens: ${JSON.stringify(text)}`, async (t) => { - const url = "https://example.com/product"; - const fixture = await browserFixture(t, (path, body) => ({ - data: path.endsWith("/read") - ? { url, title: "Product", text, truncated: false } - : { - id: body.id, - title: "Product", - url, - status: "active", - updatedAt: new Date().toISOString(), - }, - })); - const server = await createApp(fixture.db, fixture.config); - t.after(() => server.agent.stop()); - const owner = "price-spacing-user"; - const monitor = await server.agent.createMonitor(owner, { - title: "Price watch", - url, - condition: "price_below", - value: "10", - }); - await server.agent.worker.tick(); - const task = await server.agent.getTask(owner, monitor.taskId); - assert.equal(task.status, "scheduled"); - assert.equal(task.state.matched, expected); - const notifications = await fixture.db.list(owner, "notifications"); - assert.equal( - notifications.filter((entry) => entry.taskId === task.id).length, - expected ? 1 : 0, - ); + test(`matchesPrice ${expected ? "matches" : "rejects"} ${JSON.stringify(text)} (${why})`, () => { + assert.equal(matchesPrice(text, THRESHOLD), expected); }); } + +test("price watch reaches the worker outcome path through matchesPrice", async (t) => { + const url = "https://example.com/product"; + const fixture = await browserFixture(t, (path, body) => ({ + data: path.endsWith("/read") + ? { url, title: "Product", text: "Only $5, today only", truncated: false } + : { + id: body.id, + title: "Product", + url, + status: "active", + updatedAt: new Date().toISOString(), + }, + })); + const server = await createApp(fixture.db, fixture.config); + t.after(() => server.agent.stop()); + const owner = "price-boundary-user"; + const monitor = await server.agent.createMonitor(owner, { + title: "Price watch", + url, + condition: "price_below", + value: "10", + }); + await server.agent.worker.tick(); + const task = await server.agent.getTask(owner, monitor.taskId); + assert.equal(task.status, "scheduled"); + assert.equal(task.state.matched, true); + const notifications = await fixture.db.list(owner, "notifications"); + assert.equal(notifications.filter((entry) => entry.taskId === task.id).length, 1); +});