diff --git a/apps/server/src/engine/service.ts b/apps/server/src/engine/service.ts index 0e071ba87..60bccd193 100644 --- a/apps/server/src/engine/service.ts +++ b/apps/server/src/engine/service.ts @@ -1089,11 +1089,14 @@ export class AgentService { const currentHash = hash(text); const previousHash = typeof task.state.lastHash === "string" ? task.state.lastHash : monitor.lastHash; + // Collapse whitespace in the watched text the same way as in the page text, so a + // trailing space from a phone keyboard or a line break in the value still matches. + const wanted = monitor.value.replace(/\s+/g, " ").trim().toLowerCase(); const matched = monitor.condition === "change" ? Boolean(previousHash && previousHash !== currentHash) : monitor.condition === "contains" - ? text.toLowerCase().includes(monitor.value.toLowerCase()) + ? text.toLowerCase().includes(wanted) : this.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. diff --git a/tests/monitor-contains-spacing.test.ts b/tests/monitor-contains-spacing.test.ts new file mode 100644 index 000000000..6acf2b91b --- /dev/null +++ b/tests/monitor-contains-spacing.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 [value, text, expected] of [ + ["In stock", "Status: In stock", true], + ["In stock ", "Status: In stock", true], + [" in stock", "Status: In stock", true], + ["In stock", "Status: In stock.", true], + ["In\nstock", "Status: In\n stock", true], + ["In\u00a0stock", "Status: In stock now", true], + ["Sold out ", "Status: In stock", false], +] as const) { + test(`contains watch matches ${JSON.stringify(value)} in ${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 = "contains-spacing-user"; + const monitor = await server.agent.createMonitor(owner, { + title: "Stock watch", + url, + condition: "contains", + value, + }); + 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, + ); + }); +}