diff --git a/apps/x/apps/main/src/meeting-detect/browser-match.test.ts b/apps/x/apps/main/src/meeting-detect/browser-match.test.ts index 6fbc80bf..9fca5012 100644 --- a/apps/x/apps/main/src/meeting-detect/browser-match.test.ts +++ b/apps/x/apps/main/src/meeting-detect/browser-match.test.ts @@ -1,5 +1,5 @@ import { describe, it, expect } from "vitest"; -import { matchTitleOrUrl } from "./browser-match.js"; +import { matchTitleOrUrl, pickBestMatch } from "./browser-match.js"; describe("matchTitleOrUrl", () => { it("matches Google Meet by URL", () => { @@ -22,8 +22,8 @@ describe("matchTitleOrUrl", () => { expect(m?.platform).toBe("zoom-web"); }); - it("matches Teams web", () => { - const m = matchTitleOrUrl("Meeting | Microsoft Teams", "https://teams.microsoft.com/_#/calendarv2"); + it("matches Teams web on a real meeting (meetup-join) URL", () => { + const m = matchTitleOrUrl("Meeting | Microsoft Teams", "https://teams.microsoft.com/l/meetup-join/19%3ameeting_abc"); expect(m?.platform).toBe("teams-web"); }); @@ -32,6 +32,22 @@ describe("matchTitleOrUrl", () => { expect(m).toBeNull(); }); + // Tightened rules — being open is not being in a call (issue #562 follow-up). + it("does NOT match a plain Slack tab (DM/channel open, no huddle)", () => { + const m = matchTitleOrUrl("Gagan (DM) - rowboat - Slack", "https://app.slack.com/client/T077R8M5U94/D0B77701AN7"); + expect(m).toBeNull(); + }); + + it("matches a Slack huddle by its title marker", () => { + const m = matchTitleOrUrl("Huddle in general - rowboat - Slack", "https://app.slack.com/client/T077/C123"); + expect(m?.platform).toBe("slack-huddle"); + }); + + it("does NOT match a Teams calendar/chat tab (no meetup-join)", () => { + const m = matchTitleOrUrl("Calendar | Microsoft Teams", "https://teams.microsoft.com/_#/calendarv2"); + expect(m).toBeNull(); + }); + it("returns null for empty input", () => { expect(matchTitleOrUrl(undefined, undefined)).toBeNull(); expect(matchTitleOrUrl("", "")).toBeNull(); @@ -42,3 +58,54 @@ describe("matchTitleOrUrl", () => { expect(m?.platform).toBe("zoom-web"); }); }); + +describe("pickBestMatch", () => { + // Verbatim tab set from the live session in issue #562: a Slack DM tab sat + // in front of the real Google Meet call. The old first-match logic labeled + // the popup "Slack huddle"; priority must now pick Google Meet — and the + // plain Slack tab must not match at all under the tightened rules. + const LIVE_TABS = [ + "https://www.coursera.org/learn/dao-3022/lecture/3d0S8/benchmarking-evaluation-part-1", + "Benchmarking & Evaluation- Part 1 | Coursera", + "https://www.youtube.com/watch?v=qt2XslRMOto", + "(58) Inside India's Wealth Gap ... - YouTube", + "https://app.slack.com/client/T077R8M5U94/D0B77701AN7", + "Gagan (DM) - rowboat - Slack", + "https://github.com/rowboatlabs/rowboat/pull/562", + "feat: detect meeting joins ... · Pull Request #562", + "https://mail.google.com/mail/u/0/?tab=rm&ogbl#inbox", + "Inbox (4,067) - prakhar9999pandey@gmail.com - Gmail", + "https://meet.google.com/uaz-funz-pvy?authuser=0", + "Meet – uaz-funz-pvy", + ]; + + it("picks Google Meet over a backgrounded plain Slack tab (the #562 bug)", () => { + const m = pickBestMatch(LIVE_TABS); + expect(m?.platform).toBe("google-meet"); + expect(m?.hint).toContain("meet.google.com/uaz-funz-pvy"); + }); + + it("prioritizes google-meet over a genuine slack-huddle when both are open", () => { + const m = pickBestMatch([ + "Huddle in general - rowboat - Slack", + "https://meet.google.com/abc-defg-hij", + ]); + expect(m?.platform).toBe("google-meet"); + }); + + it("still returns the slack-huddle when it is the only meeting tab", () => { + const m = pickBestMatch([ + "Inbox - Gmail", + "Huddle in general - rowboat - Slack", + ]); + expect(m?.platform).toBe("slack-huddle"); + }); + + it("returns null when no tab is an actual meeting", () => { + expect(pickBestMatch([ + "Gagan (DM) - rowboat - Slack", + "Calendar | Microsoft Teams", + "Inbox - Gmail", + ])).toBeNull(); + }); +}); diff --git a/apps/x/apps/main/src/meeting-detect/browser-match.ts b/apps/x/apps/main/src/meeting-detect/browser-match.ts index 6de404f9..e91528f7 100644 --- a/apps/x/apps/main/src/meeting-detect/browser-match.ts +++ b/apps/x/apps/main/src/meeting-detect/browser-match.ts @@ -14,23 +14,39 @@ interface TitleRule { needles: string[]; } -// Substrings we look for in the foreground window title (or URL when we -// have it). On Chrome/Edge/Firefox the page title is embedded in the window -// title, which is the most reliable cross-platform signal. -// Meet page title: "Meet - Daily Standup" → matches "meet -" -// Zoom web client: "Zoom Meeting" → matches "zoom meeting" -// Teams web: " | Microsoft Teams" → matches "microsoft teams" +// Substrings that indicate the user is ACTIVELY IN A CALL — not merely that the +// app happens to be open in a tab. Bare domains ("app.slack.com", +// "teams.microsoft.com") match any Slack DM or Teams calendar tab, so we require +// call-specific URL paths or title markers instead. +// Meet: meeting URLs are meet.google.com/; title "Meet - ". +// Zoom: web client lives at zoom.us/j/ or zoom.us/wc/. +// Teams: a live meeting join URL contains "meetup-join" (teams.microsoft.com +// or teams.live.com); the bare domain (calendar, chat) does not. +// Slack: a huddle shows "huddle" in the tab title; a plain Slack tab does not. +// Webex: meeting URLs contain webex.com/meet or /wbxmjs. const RULES: TitleRule[] = [ - { platform: "google-meet", needles: ["meet.google.com", "google meet", "meet -", "meet —", "meet |"] }, + { platform: "google-meet", needles: ["meet.google.com/", "google meet", "meet -", "meet —", "meet |"] }, { platform: "zoom-web", needles: ["zoom.us/j/", "zoom.us/wc/", "zoom meeting"] }, - { platform: "teams-web", needles: ["teams.microsoft.com", "microsoft teams"] }, - { platform: "slack-huddle", needles: ["app.slack.com", "slack huddle"] }, + { platform: "teams-web", needles: ["meetup-join", "teams.live.com/meet"] }, { platform: "webex-web", needles: ["webex.com/meet", "webex.com/wbxmjs", "webex meeting"] }, + { platform: "slack-huddle", needles: ["huddle"] }, +]; + +// When several tabs match different platforms (e.g. a Slack DM open behind the +// real Google Meet call), prefer the more definitive meeting. Dedicated meeting +// platforms outrank a Slack huddle, whose "huddle" title marker is the weakest +// signal. Lower index = higher precedence. +const PLATFORM_PRIORITY: BrowserMeetingPlatform[] = [ + "google-meet", + "zoom-web", + "teams-web", + "webex-web", + "slack-huddle", ]; /** - * Look at the foreground window. If it's a browser and the title matches a - * known meeting URL/platform, return a match. Returns null otherwise. + * Look at the browser's open tabs/windows. If any matches a known meeting + * URL/platform, return the highest-priority match. Returns null otherwise. * * Caller is expected to only invoke this when the detector classified the * mic-holder as `kind: "browser"`. That keeps active-win calls cheap — we @@ -39,13 +55,28 @@ const RULES: TitleRule[] = [ export async function matchBrowserMeeting(executable?: string): Promise { const snap = await getWindowSnapshot(executable); if (!snap) return null; - // Scan ALL known window titles — on Windows tasklist returns every window, - // so even a backgrounded Meet tab still matches while Chrome holds the mic. - for (const title of snap.titles) { + return pickBestMatch(snap.titles); +} + +/** + * Scan every tab title/URL, collect matches, and return the highest-priority + * one — not just the first tab that matches. This prevents a backgrounded Slack + * DM from beating the real Google Meet call to the result. Pure; exposed for tests. + */ +export function pickBestMatch(titles: string[]): BrowserMeetingMatch | null { + let best: BrowserMeetingMatch | null = null; + let bestRank = Number.POSITIVE_INFINITY; + for (const title of titles) { const m = matchTitleOrUrl(title, undefined); - if (m) return m; + if (!m) continue; + const rank = PLATFORM_PRIORITY.indexOf(m.platform); + if (rank < bestRank) { + best = m; + bestRank = rank; + if (rank === 0) break; // nothing outranks the top platform + } } - return null; + return best; } /** Pure matcher — exposed for tests; no OS calls. */ diff --git a/apps/x/apps/main/src/meeting-detect/probe-macos.test.ts b/apps/x/apps/main/src/meeting-detect/probe-macos.test.ts new file mode 100644 index 00000000..95ada70c --- /dev/null +++ b/apps/x/apps/main/src/meeting-detect/probe-macos.test.ts @@ -0,0 +1,76 @@ +import { describe, it, expect } from "vitest"; +import { parseAssertions } from "./probe-macos.js"; + +// Verbatim `pmset -g assertions` capture from a live macOS session (issue #562): +// Google Chrome is in a Google Meet call with the camera on, while caffeinate +// and powerd hold unrelated PreventUserIdleSystemSleep locks. The browser holds +// a NoIdleSleepAssertion ("WebRTC has active PeerConnections") — the regex must +// match that and ignore the System-sleep noise. +const PMSET_WEBRTC_CALL = `2026-06-11 22:59:21 +0530 +Assertion status system-wide: + BackgroundTask 0 + ApplePushServiceTask 0 + UserIsActive 1 + PreventUserIdleDisplaySleep 0 + SoftwareUpdateTask 0 + PreventSystemSleep 0 + ExternalMedia 0 + PreventUserIdleSystemSleep 1 + NetworkClientActive 0 +Listed by owning process: + pid 171(WindowServer): [0x00003a1100099303] 00:00:00 UserIsActive named: "com.apple.iohideventsystem.queue.tickle serviceID:100000944 service:AppleMultitouchDevice product:Apple Internal Keyboard / Trackpad eventType:11" +\tTimeout will fire in 119 secs Action=TimeoutActionRelease + pid 664(Google Chrome): [0x00003c6000019337] 00:00:59 NoIdleSleepAssertion named: "WebRTC has active PeerConnections" + pid 72851(caffeinate): [0x00003b3700019329] 00:00:12 PreventUserIdleSystemSleep named: "caffeinate command-line tool" +\tDetails: caffeinate asserting for 300 secs +\tLocalized=THE CAFFEINATE TOOL IS PREVENTING SLEEP. +\tTimeout will fire in 287 secs Action=TimeoutActionRelease + pid 107(powerd): [0x00003a1100019304] 00:06:26 PreventUserIdleSystemSleep named: "Powerd - Prevent sleep while display is on" +No kernel assertions. +`; + +describe("parseAssertions", () => { + it("matches a browser's NoIdleSleepAssertion (WebRTC) and ignores System-sleep noise", () => { + const users = parseAssertions(PMSET_WEBRTC_CALL); + + // Chrome (NoIdleSleepAssertion) is in; caffeinate + powerd + // (PreventUserIdleSystemSleep) are filtered out. + expect(users).toEqual([{ executable: "Google Chrome", pid: 664 }]); + }); + + it("matches a native app's PreventUserIdleDisplaySleep assertion", () => { + const stdout = [ + "Listed by owning process:", + ` pid 4711(zoom.us): [0x00000ff100099303] 00:23:14 PreventUserIdleDisplaySleep named: "zoom.us is in a meeting"`, + ].join("\n"); + + expect(parseAssertions(stdout)).toEqual([{ executable: "zoom.us", pid: 4711 }]); + }); + + it("does NOT match PreventUserIdleSystemSleep (caffeinate/powerd noise)", () => { + const stdout = + ` pid 72851(caffeinate): [0x00003b3700019329] 00:00:12 PreventUserIdleSystemSleep named: "caffeinate command-line tool"`; + + expect(parseAssertions(stdout)).toEqual([]); + }); + + it("dedupes a pid that holds multiple matching assertions (first wins)", () => { + const stdout = [ + ` pid 664(Google Chrome): [0xaaa] 00:00:59 NoIdleSleepAssertion named: "WebRTC has active PeerConnections"`, + ` pid 664(Google Chrome): [0xbbb] 00:01:00 PreventUserIdleDisplaySleep named: "screen share"`, + ].join("\n"); + + expect(parseAssertions(stdout)).toEqual([{ executable: "Google Chrome", pid: 664 }]); + }); + + it("returns an empty list when nothing holds a meeting assertion", () => { + const stdout = [ + "Assertion status system-wide:", + " PreventUserIdleDisplaySleep 0", + "Listed by owning process:", + ` pid 171(WindowServer): [0x00003a1100099303] 00:00:00 UserIsActive named: "tickle"`, + ].join("\n"); + + expect(parseAssertions(stdout)).toEqual([]); + }); +}); diff --git a/apps/x/apps/main/src/meeting-detect/probe-macos.ts b/apps/x/apps/main/src/meeting-detect/probe-macos.ts index bc2fddd2..4759880c 100644 --- a/apps/x/apps/main/src/meeting-detect/probe-macos.ts +++ b/apps/x/apps/main/src/meeting-detect/probe-macos.ts @@ -17,7 +17,18 @@ const execFileAsync = promisify(execFile); // // Output format from `pmset -g assertions`: // pid 4711(zoom.us): [0x00000ff...] 00:23:14 PreventUserIdleDisplaySleep named: "..." -const ASSERTION_LINE = /^\s*pid\s+(\d+)\((.+?)\):\s+\[[^\]]+\]\s+\S+\s+(PreventUserIdle\w+)/; +// pid 664(Google Chrome): [0x...] 00:00:59 NoIdleSleepAssertion named: "WebRTC has active PeerConnections" +// +// We key on two assertion types: +// - PreventUserIdleDisplaySleep — native meeting apps keep the screen on +// during a call. We deliberately do NOT match PreventUserIdleSystemSleep, +// which is held by `caffeinate`, `powerd`, downloads, etc. (noise). +// - NoIdleSleepAssertion — browsers (Chrome/Arc/Safari/etc.) hold this with +// the reason "WebRTC has active PeerConnections" whenever a WebRTC call is +// live (Google Meet, Zoom web, Teams web, Discord, Slack huddles). This is +// the most reliable browser-meeting signal. False positives (e.g. a WebRTC +// YouTube tab) are filtered downstream by browser-match's tab-title check. +const ASSERTION_LINE = /^\s*pid\s+(\d+)\((.+?)\):\s+\[[^\]]+\]\s+\S+\s+(PreventUserIdleDisplaySleep|NoIdleSleepAssertion)/; export class MacOsMicProbe implements MicProbe { async probe(): Promise { @@ -32,16 +43,25 @@ export class MacOsMicProbe implements MicProbe { return []; } - const seen = new Map(); - for (const line of stdout.split("\n")) { - const m = ASSERTION_LINE.exec(line); - if (!m) continue; - const pid = Number(m[1]); - const command = m[2].trim(); - if (!Number.isFinite(pid)) continue; - if (seen.has(pid)) continue; - seen.set(pid, { executable: command, pid }); - } - return Array.from(seen.values()); + return parseAssertions(stdout); } } + +/** + * Parse `pmset -g assertions` stdout into the distinct processes holding a + * meeting-relevant assertion. Pure (no OS calls) so it's unit-testable against + * captured pmset output. One entry per pid; the first matching line wins. + */ +export function parseAssertions(stdout: string): MicUser[] { + const seen = new Map(); + for (const line of stdout.split("\n")) { + const m = ASSERTION_LINE.exec(line); + if (!m) continue; + const pid = Number(m[1]); + const command = m[2].trim(); + if (!Number.isFinite(pid)) continue; + if (seen.has(pid)) continue; + seen.set(pid, { executable: command, pid }); + } + return Array.from(seen.values()); +} diff --git a/apps/x/apps/main/src/meeting-detect/service.ts b/apps/x/apps/main/src/meeting-detect/service.ts index 0da141f5..64f6f2ce 100644 --- a/apps/x/apps/main/src/meeting-detect/service.ts +++ b/apps/x/apps/main/src/meeting-detect/service.ts @@ -134,7 +134,7 @@ export class MeetingDetectService { } else { this.notifier.notify(payload.notify); } - await this.suppression.markNotified(event.sessionKey); + await this.suppression.markNotified(event.sessionKey, event.executable); console.log(`[MeetingDetect] popup fired for ${event.executable} (kind=${event.kind}, eventId=${correlated?.eventId ?? "ad-hoc"})`); } catch (err) { console.error("[MeetingDetect] popup failed:", err); diff --git a/apps/x/apps/main/src/meeting-detect/suppression.test.ts b/apps/x/apps/main/src/meeting-detect/suppression.test.ts index ab521f11..fdddba66 100644 --- a/apps/x/apps/main/src/meeting-detect/suppression.test.ts +++ b/apps/x/apps/main/src/meeting-detect/suppression.test.ts @@ -16,13 +16,37 @@ describe("Suppression", () => { }); it("blocks re-popup for the same session once marked notified", async () => { - await suppression.markNotified("zoom.us#100"); + await suppression.markNotified("zoom.us#100", "zoom.us"); expect(suppression.shouldNotify("zoom.us#100", "zoom.us")).toBe(false); }); - it("allows a different session for the same exe", async () => { - await suppression.markNotified("zoom.us#100"); - expect(suppression.shouldNotify("zoom.us#101", "zoom.us")).toBe(true); + it("blocks a different session for the same exe within the notify cooldown", async () => { + // A flaky mic assertion clears the session and re-detects under a new key; + // the per-app cooldown must suppress the duplicate popup (issue #562 follow-up). + const t0 = new Date(); + await suppression.markNotified("zoom.us#100", "zoom.us", t0); + const soon = new Date(t0.getTime() + 30 * 1000); // 30s later — within the 90s cooldown + expect(suppression.shouldNotify("zoom.us#101", "zoom.us", soon)).toBe(false); + }); + + it("allows the same exe again once the notify cooldown has elapsed", async () => { + const t0 = new Date(); + await suppression.markNotified("zoom.us#100", "zoom.us", t0); + const after = new Date(t0.getTime() + 100 * 1000); // 100s — past the 90s cooldown + // Cooldown GC drops stale entries on reload, mirroring the dismiss-cooldown test. + const reloaded = new Suppression(store); + await reloaded.init(); + expect(reloaded.shouldNotify("zoom.us#101", "zoom.us", after)).toBe(true); + }); + + it("keeps the cooldown across clearSession (the flicker case)", async () => { + const t0 = new Date(); + await suppression.markNotified("Google Chrome#664", "Google Chrome", t0); + // Mic assertion blinks out → detector clears the session. + await suppression.clearSession("Google Chrome#664"); + // Same session re-detected moments later must NOT re-popup. + const soon = new Date(t0.getTime() + 30 * 1000); + expect(suppression.shouldNotify("Google Chrome#664", "Google Chrome", soon)).toBe(false); }); it("respects the dismiss cooldown window", async () => { @@ -51,7 +75,7 @@ describe("Suppression", () => { }); it("persists state through save/load", async () => { - await suppression.markNotified("zoom.us#100"); + await suppression.markNotified("zoom.us#100", "zoom.us"); await suppression.muteApp("Discord"); const snap = store.snapshot(); diff --git a/apps/x/apps/main/src/meeting-detect/suppression.ts b/apps/x/apps/main/src/meeting-detect/suppression.ts index c2b42ece..db6aa3e5 100644 --- a/apps/x/apps/main/src/meeting-detect/suppression.ts +++ b/apps/x/apps/main/src/meeting-detect/suppression.ts @@ -5,12 +5,21 @@ import { WorkDir } from "@x/core/dist/config/config.js"; const STATE_FILE = path.join(WorkDir, "meeting_detect_state.json"); // Don't re-popup for the same exe within this window if the user dismissed. const DISMISS_COOLDOWN_MS = 30 * 60 * 1000; +// After showing a popup for an app, stay quiet for the SAME app for this long — +// even if the mic/WebRTC assertion flickers and the detector clears + re-fires +// the session. The macOS pmset assertion blinks out for stretches of a single +// call (observed dropouts maxed around 84s), which would otherwise re-fire a +// fresh popup every time it reappears. 90s covers the worst observed flicker. +const NOTIFY_COOLDOWN_MS = 90 * 1000; // Drop session-key entries older than 24h. const SESSION_TTL_MS = 24 * 60 * 60 * 1000; interface SuppressionState { // Mic sessions we've already shown a popup for — keyed by detector sessionKey. notifiedSessions: Record; + // Apps we've recently shown a popup for — keyed by exe basename. Drives the + // per-app cooldown; survives clearSession() so a flickering session can't spam. + recentlyNotified: Record; // User explicitly dismissed for this exe at this time. recentlyDismissed: Record; // Permanent "never offer for this app" list — exe substring matches. @@ -18,7 +27,7 @@ interface SuppressionState { } function empty(): SuppressionState { - return { notifiedSessions: {}, recentlyDismissed: {}, mutedApps: [] }; + return { notifiedSessions: {}, recentlyNotified: {}, recentlyDismissed: {}, mutedApps: [] }; } export interface SuppressionStore { @@ -52,6 +61,7 @@ function normalize(raw: unknown): SuppressionState { const obj = raw as Partial; return { notifiedSessions: obj.notifiedSessions && typeof obj.notifiedSessions === "object" ? obj.notifiedSessions : {}, + recentlyNotified: obj.recentlyNotified && typeof obj.recentlyNotified === "object" ? obj.recentlyNotified : {}, recentlyDismissed: obj.recentlyDismissed && typeof obj.recentlyDismissed === "object" ? obj.recentlyDismissed : {}, mutedApps: Array.isArray(obj.mutedApps) ? obj.mutedApps.filter((x) => typeof x === "string") : [], }; @@ -77,8 +87,19 @@ export class Suppression { if (this.isMuted(executable)) return false; if (this.state.notifiedSessions[sessionKey]) return false; - const dismissKey = dismissKeyFor(executable); - const recent = this.state.recentlyDismissed[dismissKey]; + const appKey = dismissKeyFor(executable); + + // Per-app cooldown: once we've popped for this app, stay quiet for the + // window even if the session cleared and re-fired (flaky mic assertion). + const lastNotified = this.state.recentlyNotified[appKey]; + if (lastNotified) { + const notifiedAt = Date.parse(lastNotified.notifiedAt); + if (Number.isFinite(notifiedAt) && now.getTime() - notifiedAt < NOTIFY_COOLDOWN_MS) { + return false; + } + } + + const recent = this.state.recentlyDismissed[appKey]; if (recent) { const dismissedAt = Date.parse(recent.dismissedAt); if (Number.isFinite(dismissedAt) && now.getTime() - dismissedAt < DISMISS_COOLDOWN_MS) { @@ -88,8 +109,11 @@ export class Suppression { return true; } - async markNotified(sessionKey: string, now: Date = new Date()): Promise { + async markNotified(sessionKey: string, executable: string, now: Date = new Date()): Promise { this.state.notifiedSessions[sessionKey] = { notifiedAt: now.toISOString() }; + // App-level stamp drives the cooldown and is intentionally NOT removed by + // clearSession() — that's what makes a flickering session stop re-popping. + this.state.recentlyNotified[dismissKeyFor(executable)] = { notifiedAt: now.toISOString() }; await this.persist(); } @@ -146,12 +170,17 @@ function gc(state: SuppressionState): SuppressionState { const ts = Date.parse(v.notifiedAt); if (Number.isFinite(ts) && now - ts < SESSION_TTL_MS) sessions[k] = v; } + const notified: SuppressionState["recentlyNotified"] = {}; + for (const [k, v] of Object.entries(state.recentlyNotified)) { + const ts = Date.parse(v.notifiedAt); + if (Number.isFinite(ts) && now - ts < NOTIFY_COOLDOWN_MS) notified[k] = v; + } const dismissed: SuppressionState["recentlyDismissed"] = {}; for (const [k, v] of Object.entries(state.recentlyDismissed)) { const ts = Date.parse(v.dismissedAt); if (Number.isFinite(ts) && now - ts < DISMISS_COOLDOWN_MS) dismissed[k] = v; } - return { notifiedSessions: sessions, recentlyDismissed: dismissed, mutedApps: state.mutedApps }; + return { notifiedSessions: sessions, recentlyNotified: notified, recentlyDismissed: dismissed, mutedApps: state.mutedApps }; } /** In-memory store for tests. */