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
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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": {
Expand Down
14 changes: 13 additions & 1 deletion src/lib/galaxy/client/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,19 @@ export class GalaxyClient {
const { interaction, ...eventProperties } = properties ?? {
interaction: 'click'
};
const [namespace, component, eventName] = event.split('.');
// `event` is "<namespace>.<component>.<eventName>". `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,
Expand Down
20 changes: 20 additions & 0 deletions test/extensionless-resolver.mjs
Original file line number Diff line number Diff line change
@@ -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;
}
}
52 changes: 52 additions & 0 deletions test/galaxy-client.test.js
Original file line number Diff line number Diff line change
@@ -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');
});
4 changes: 4 additions & 0 deletions test/register.mjs
Original file line number Diff line number Diff line change
@@ -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);