From fa7b4d7d81a471560a37834d7310568ca85543d2 Mon Sep 17 00:00:00 2001 From: Lionel Palacin Date: Wed, 22 Jul 2026 17:57:49 +0100 Subject: [PATCH] fix(galaxy): don't mis-split event namespace when it contains a dot Co-Authored-By: Claude Opus 4.8 (1M context) --- package.json | 1 + src/lib/galaxy/client/index.js | 14 ++++++++- test/extensionless-resolver.mjs | 20 +++++++++++++ test/galaxy-client.test.js | 52 +++++++++++++++++++++++++++++++++ test/register.mjs | 4 +++ 5 files changed, 90 insertions(+), 1 deletion(-) create mode 100644 test/extensionless-resolver.mjs create mode 100644 test/galaxy-client.test.js create mode 100644 test/register.mjs diff --git a/package.json b/package.json index da3173f..7988018 100644 --- a/package.json +++ b/package.json @@ -8,6 +8,7 @@ "build": "next build", "start": "next start", "lint": "next lint", + "test": "node --import ./test/register.mjs --test \"test/*.test.js\"", "postinstall": "patch-package" }, "dependencies": { diff --git a/src/lib/galaxy/client/index.js b/src/lib/galaxy/client/index.js index 3127098..acb8991 100644 --- a/src/lib/galaxy/client/index.js +++ b/src/lib/galaxy/client/index.js @@ -41,7 +41,19 @@ export class GalaxyClient { const { interaction, ...eventProperties } = properties ?? { interaction: 'click' }; - const [namespace, component, eventName] = event.split('.'); + // `event` is "..". `namespace` is free-form + // (it can be a package/gem name, which may itself contain dots, e.g. + // "dashboard: ruamel.yaml" or "dashboard: llm.rb"), while `component` and + // `eventName` are always short fixed literals chosen by the caller (e.g. + // "window", "load") and never contain dots. Splitting naively on every "." + // and taking the first three parts silently truncates/misaligns + // component/eventName whenever namespace has a dot in it. Instead, take the + // last two segments as component/eventName and treat everything before + // them as namespace, which is safe either way and fixes that case. + const parts = event.split('.'); + const eventName = parts.pop(); + const component = parts.pop(); + const namespace = parts.join('.'); const payloadProperties = this.getPayloadProperties(); const galaxyEvent = { application: this.application, diff --git a/test/extensionless-resolver.mjs b/test/extensionless-resolver.mjs new file mode 100644 index 0000000..e2f5e20 --- /dev/null +++ b/test/extensionless-resolver.mjs @@ -0,0 +1,20 @@ +// The app source uses extensionless relative imports (e.g. `import { logFns } +// from '../logging'`), which webpack/Next.js resolve at build time but Node's +// native ESM resolver does not. This loader hook lets `node --test` import the +// source directly by retrying a failed relative specifier with a `.js` +// extension appended. Registered via `--import ./test/register.mjs`. +export async function resolve(specifier, context, nextResolve) { + try { + return await nextResolve(specifier, context); + } catch (error) { + if ( + (error?.code === 'ERR_MODULE_NOT_FOUND' || + error?.code === 'ERR_UNSUPPORTED_DIR_IMPORT') && + (specifier.startsWith('./') || specifier.startsWith('../')) && + !/\.[cm]?js$/.test(specifier) + ) { + return await nextResolve(`${specifier}.js`, context); + } + throw error; + } +} diff --git a/test/galaxy-client.test.js b/test/galaxy-client.test.js new file mode 100644 index 0000000..0b49a70 --- /dev/null +++ b/test/galaxy-client.test.js @@ -0,0 +1,52 @@ +import test from 'node:test'; +import assert from 'node:assert/strict'; +import { GalaxyClient } from '../src/lib/galaxy/client/index.js'; + +function trackAndGetLastEvent(client, event, properties) { + client.track(event, properties); + return client.eventsQueue[client.eventsQueue.length - 1]; +} + +function makeClient() { + return new GalaxyClient({ + application: 'TEST', + getUserId: () => 'test-user', + getSessionId: () => 'test-session', + getContext: () => ({}) + }); +} + +test('namespace without dots splits as before', () => { + const e = trackAndGetLastEvent(makeClient(), 'landing.window.load'); + assert.equal(e.namespace, 'landing'); + assert.equal(e.component, 'window'); + assert.equal(e.event, 'load'); +}); + +test('namespace with one dot (real ClickGems anomaly: llm.rb)', () => { + const e = trackAndGetLastEvent(makeClient(), 'dashboard: llm.rb.window.load'); + assert.equal(e.namespace, 'dashboard: llm.rb'); + assert.equal(e.component, 'window'); + assert.equal(e.event, 'load'); +}); + +test('namespace with one dot (real ClickGems anomaly: savon-ng-1.6)', () => { + const e = trackAndGetLastEvent(makeClient(), 'dashboard: savon-ng-1.6.window.blur'); + assert.equal(e.namespace, 'dashboard: savon-ng-1.6'); + assert.equal(e.component, 'window'); + assert.equal(e.event, 'blur'); +}); + +test('namespace with one dot (dormant ClickPy case: ruamel.yaml)', () => { + const e = trackAndGetLastEvent(makeClient(), 'dashboard: ruamel.yaml.window.load'); + assert.equal(e.namespace, 'dashboard: ruamel.yaml'); + assert.equal(e.component, 'window'); + assert.equal(e.event, 'load'); +}); + +test('click-style event (nav.query.select) is unaffected', () => { + const e = trackAndGetLastEvent(makeClient(), 'nav.query.select', { interaction: 'click' }); + assert.equal(e.namespace, 'nav'); + assert.equal(e.component, 'query'); + assert.equal(e.event, 'select'); +}); diff --git a/test/register.mjs b/test/register.mjs new file mode 100644 index 0000000..0c6657a --- /dev/null +++ b/test/register.mjs @@ -0,0 +1,4 @@ +// Registers the extensionless-import resolver hook so `node --test` can import +// the bundler-style app source. Usage: node --import ./test/register.mjs --test test/ +import { register } from 'node:module'; +register('./extensionless-resolver.mjs', import.meta.url);