mirror of
https://github.com/langbot-app/LangBot.git
synced 2026-11-03 22:32:27 +08:00
fix: resolve master conflicts and align connection mode regression
This commit is contained in:
@@ -1,175 +0,0 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import ts from 'typescript';
|
||||
|
||||
// The floating assistant button docks against a viewport edge, collapses into a
|
||||
// short blue rail, and re-expands on hover. The transition rules are pure so
|
||||
// they can be pinned here: the race this guards against is a button that drops
|
||||
// under the cursor and never becomes visible again.
|
||||
const sourcePath = new URL(
|
||||
'../../src/app/home/components/assistant-dock.ts',
|
||||
import.meta.url,
|
||||
);
|
||||
assert.ok(fs.existsSync(sourcePath), 'Missing assistant dock module');
|
||||
const compiled = ts.transpileModule(fs.readFileSync(sourcePath, 'utf8'), {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS },
|
||||
}).outputText;
|
||||
const loaded = { exports: {} };
|
||||
new Function('require', 'module', 'exports', compiled)(
|
||||
() => {
|
||||
throw new Error('assistant-dock must stay dependency free');
|
||||
},
|
||||
loaded,
|
||||
loaded.exports,
|
||||
);
|
||||
const {
|
||||
ASSISTANT_BUTTON_SIZE,
|
||||
ASSISTANT_SNAP_THRESHOLD,
|
||||
ASSISTANT_RAIL_WIDTH,
|
||||
clampAssistantPosition,
|
||||
resolveAssistantEdge,
|
||||
shouldCollapseRail,
|
||||
shouldExpandRail,
|
||||
restingRailExpanded,
|
||||
} = loaded.exports;
|
||||
|
||||
const viewport = { width: 1280, height: 800 };
|
||||
const maxX = viewport.width - ASSISTANT_BUTTON_SIZE;
|
||||
|
||||
test('clamping keeps the button fully inside the viewport', () => {
|
||||
assert.deepEqual(clampAssistantPosition(-50, -50, viewport), { x: 0, y: 0 });
|
||||
assert.deepEqual(clampAssistantPosition(9999, 9999, viewport), {
|
||||
x: maxX,
|
||||
y: viewport.height - ASSISTANT_BUTTON_SIZE,
|
||||
});
|
||||
assert.deepEqual(clampAssistantPosition(400, 300, viewport), {
|
||||
x: 400,
|
||||
y: 300,
|
||||
});
|
||||
});
|
||||
|
||||
test('a narrow viewport never produces a negative travel range', () => {
|
||||
assert.deepEqual(clampAssistantPosition(10, 10, { width: 20, height: 20 }), {
|
||||
x: 0,
|
||||
y: 0,
|
||||
});
|
||||
});
|
||||
|
||||
test('positions within the snap threshold dock to the nearest edge', () => {
|
||||
assert.equal(resolveAssistantEdge(0, viewport), 'left');
|
||||
assert.equal(
|
||||
resolveAssistantEdge(ASSISTANT_SNAP_THRESHOLD, viewport),
|
||||
'left',
|
||||
);
|
||||
assert.equal(resolveAssistantEdge(maxX, viewport), 'right');
|
||||
assert.equal(
|
||||
resolveAssistantEdge(maxX - ASSISTANT_SNAP_THRESHOLD, viewport),
|
||||
'right',
|
||||
);
|
||||
});
|
||||
|
||||
test('a centred position stays free floating', () => {
|
||||
assert.equal(resolveAssistantEdge(Math.round(maxX / 2), viewport), null);
|
||||
assert.equal(
|
||||
resolveAssistantEdge(ASSISTANT_SNAP_THRESHOLD + 1, viewport),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('the left edge wins ties so a centred drop is deterministic', () => {
|
||||
// A viewport sized so both gaps are inside the snap threshold at x = 0.
|
||||
const tight = { width: ASSISTANT_BUTTON_SIZE, height: 400 };
|
||||
assert.equal(resolveAssistantEdge(0, tight), 'left');
|
||||
});
|
||||
|
||||
test('hovering a docked rail expands it, hovering a free button does not', () => {
|
||||
assert.equal(
|
||||
shouldExpandRail({
|
||||
dockedEdge: 'right',
|
||||
railExpanded: false,
|
||||
dragging: false,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldExpandRail({
|
||||
dockedEdge: null,
|
||||
railExpanded: false,
|
||||
dragging: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
// Already expanded: nothing to do, so no redundant state churn on every move.
|
||||
assert.equal(
|
||||
shouldExpandRail({
|
||||
dockedEdge: 'left',
|
||||
railExpanded: true,
|
||||
dragging: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('a drag in progress never expands or collapses the rail', () => {
|
||||
const dragging = { dockedEdge: 'left', railExpanded: false, dragging: true };
|
||||
assert.equal(shouldExpandRail(dragging), false);
|
||||
assert.equal(shouldCollapseRail(dragging), false);
|
||||
});
|
||||
|
||||
test('only an *expanded* docked button collapses when the pointer leaves', () => {
|
||||
// Regression: keying this on `!railExpanded` made a revealed button
|
||||
// impossible to collapse again, so it stayed open forever after one hover.
|
||||
assert.equal(
|
||||
shouldCollapseRail({
|
||||
dockedEdge: 'left',
|
||||
railExpanded: true,
|
||||
dragging: false,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
assert.equal(
|
||||
shouldCollapseRail({
|
||||
dockedEdge: 'left',
|
||||
railExpanded: false,
|
||||
dragging: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
shouldCollapseRail({
|
||||
dockedEdge: null,
|
||||
railExpanded: true,
|
||||
dragging: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test('expand then leave round-trips back to the collapsed rail', () => {
|
||||
const docked = { dockedEdge: 'right', railExpanded: false, dragging: false };
|
||||
// Hover: reveal the button.
|
||||
assert.equal(shouldExpandRail(docked), true);
|
||||
// Pointer leaves: the revealed button must collapse again.
|
||||
assert.equal(shouldCollapseRail({ ...docked, railExpanded: true }), true);
|
||||
// A second hover re-reveals it, so the cycle is repeatable.
|
||||
assert.equal(shouldExpandRail(docked), true);
|
||||
});
|
||||
|
||||
test('dropping always starts collapsed so the rail is never stuck open', () => {
|
||||
// The pointer is still over the button on drop; the next pointermove expands.
|
||||
assert.equal(restingRailExpanded(), false);
|
||||
assert.equal(
|
||||
shouldExpandRail({
|
||||
dockedEdge: 'right',
|
||||
railExpanded: restingRailExpanded(),
|
||||
dragging: false,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test('the rail strip stays thinner than the button it replaces', () => {
|
||||
assert.ok(ASSISTANT_RAIL_WIDTH > 0);
|
||||
assert.ok(ASSISTANT_RAIL_WIDTH < ASSISTANT_BUTTON_SIZE);
|
||||
});
|
||||
@@ -0,0 +1,179 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import path from 'node:path';
|
||||
import test from 'node:test';
|
||||
import { createRequire } from 'node:module';
|
||||
import { fileURLToPath } from 'node:url';
|
||||
import ts from 'typescript';
|
||||
|
||||
const currentDirectory = path.dirname(fileURLToPath(import.meta.url));
|
||||
const sourcePath = path.resolve(
|
||||
currentDirectory,
|
||||
'../../src/app/infra/hooks/useInstalledPluginIcon.ts',
|
||||
);
|
||||
const source = fs.readFileSync(sourcePath, 'utf8');
|
||||
const compiled = ts.transpileModule(source, {
|
||||
compilerOptions: { module: ts.ModuleKind.CommonJS },
|
||||
}).outputText;
|
||||
|
||||
// --- minimal React + backend mocks so the hook can run under node:test ---
|
||||
let stateSlots = [];
|
||||
let lastCleanup;
|
||||
|
||||
const reactMock = {
|
||||
useState(initial) {
|
||||
const value = typeof initial === 'function' ? initial() : initial;
|
||||
const index = stateSlots.length;
|
||||
stateSlots.push(value);
|
||||
return [
|
||||
value,
|
||||
(next) => {
|
||||
stateSlots[index] =
|
||||
typeof next === 'function' ? next(stateSlots[index]) : next;
|
||||
},
|
||||
];
|
||||
},
|
||||
useEffect(effect) {
|
||||
const cleanup = effect();
|
||||
if (typeof cleanup === 'function') lastCleanup = cleanup;
|
||||
},
|
||||
};
|
||||
|
||||
const httpMock = {
|
||||
httpClient: {
|
||||
getAuthenticatedPluginIconURL: () => {
|
||||
throw new Error('fetch not configured for this test');
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
const sourceRequire = createRequire(sourcePath);
|
||||
|
||||
function loadHook() {
|
||||
const loadedModule = { exports: {} };
|
||||
const requireShim = (id) => {
|
||||
if (id === 'react') return reactMock;
|
||||
if (id === '@/app/infra/http') return httpMock;
|
||||
return sourceRequire(id);
|
||||
};
|
||||
new Function('require', 'module', 'exports', compiled)(
|
||||
requireShim,
|
||||
loadedModule,
|
||||
loadedModule.exports,
|
||||
);
|
||||
return loadedModule.exports.useInstalledPluginIcon;
|
||||
}
|
||||
|
||||
const revoked = [];
|
||||
globalThis.URL.revokeObjectURL = (url) => revoked.push(url);
|
||||
|
||||
function reset() {
|
||||
stateSlots = [];
|
||||
lastCleanup = undefined;
|
||||
revoked.length = 0;
|
||||
}
|
||||
|
||||
const flush = () => new Promise((resolve) => setTimeout(resolve, 0));
|
||||
|
||||
test('caches per author/name and revokes once the last subscriber leaves', async () => {
|
||||
let fetches = 0;
|
||||
let resolveFetch;
|
||||
httpMock.httpClient.getAuthenticatedPluginIconURL = () => {
|
||||
fetches += 1;
|
||||
return new Promise((resolve) => {
|
||||
resolveFetch = resolve;
|
||||
});
|
||||
};
|
||||
reset();
|
||||
const useInstalledPluginIcon = loadHook();
|
||||
|
||||
const first = useInstalledPluginIcon('acme', 'demo');
|
||||
const firstCleanup = lastCleanup;
|
||||
assert.equal(first, null);
|
||||
assert.equal(fetches, 1);
|
||||
|
||||
resolveFetch('blob:acme/demo');
|
||||
await flush();
|
||||
|
||||
// Second subscriber reuses the cached object URL synchronously.
|
||||
const second = useInstalledPluginIcon('acme', 'demo');
|
||||
const secondCleanup = lastCleanup;
|
||||
assert.equal(second, 'blob:acme/demo');
|
||||
assert.equal(fetches, 1);
|
||||
|
||||
firstCleanup();
|
||||
assert.deepEqual(revoked, []);
|
||||
secondCleanup();
|
||||
assert.deepEqual(revoked, ['blob:acme/demo']);
|
||||
|
||||
// Cache was dropped with the last subscriber, so a new mount refetches.
|
||||
useInstalledPluginIcon('acme', 'demo');
|
||||
assert.equal(fetches, 2);
|
||||
});
|
||||
|
||||
test('de-duplicates concurrent subscribers and revokes a shared icon once', async () => {
|
||||
let fetches = 0;
|
||||
let resolveFetch;
|
||||
httpMock.httpClient.getAuthenticatedPluginIconURL = () => {
|
||||
fetches += 1;
|
||||
return new Promise((resolve) => {
|
||||
resolveFetch = resolve;
|
||||
});
|
||||
};
|
||||
reset();
|
||||
const useInstalledPluginIcon = loadHook();
|
||||
|
||||
useInstalledPluginIcon('acme', 'demo');
|
||||
const firstCleanup = lastCleanup;
|
||||
useInstalledPluginIcon('acme', 'demo');
|
||||
const secondCleanup = lastCleanup;
|
||||
assert.equal(fetches, 1);
|
||||
|
||||
resolveFetch('blob:shared');
|
||||
await flush();
|
||||
|
||||
firstCleanup();
|
||||
assert.deepEqual(revoked, []);
|
||||
secondCleanup();
|
||||
assert.deepEqual(revoked, ['blob:shared']);
|
||||
});
|
||||
|
||||
test('caches nothing and revokes nothing when the authenticated fetch fails', async () => {
|
||||
let fetches = 0;
|
||||
httpMock.httpClient.getAuthenticatedPluginIconURL = () => {
|
||||
fetches += 1;
|
||||
return Promise.reject(new Error('not found'));
|
||||
};
|
||||
reset();
|
||||
const useInstalledPluginIcon = loadHook();
|
||||
|
||||
const value = useInstalledPluginIcon('acme', 'missing');
|
||||
const cleanup = lastCleanup;
|
||||
assert.equal(value, null);
|
||||
|
||||
await flush();
|
||||
assert.equal(useInstalledPluginIcon('acme', 'missing'), null);
|
||||
assert.equal(fetches, 2); // failures are not negatively cached
|
||||
|
||||
cleanup();
|
||||
assert.deepEqual(revoked, []);
|
||||
});
|
||||
|
||||
test('returns a marketplace URL verbatim without fetching', () => {
|
||||
let fetches = 0;
|
||||
httpMock.httpClient.getAuthenticatedPluginIconURL = () => {
|
||||
fetches += 1;
|
||||
return Promise.resolve('blob:unused');
|
||||
};
|
||||
reset();
|
||||
const useInstalledPluginIcon = loadHook();
|
||||
|
||||
const value = useInstalledPluginIcon(
|
||||
'acme',
|
||||
'demo',
|
||||
'https://space.langbot.app/icon',
|
||||
);
|
||||
assert.equal(value, 'https://space.langbot.app/icon');
|
||||
assert.equal(fetches, 0);
|
||||
assert.equal(lastCleanup, undefined);
|
||||
});
|
||||
@@ -0,0 +1,131 @@
|
||||
import assert from 'node:assert/strict';
|
||||
import fs from 'node:fs';
|
||||
import test from 'node:test';
|
||||
import ts from 'typescript';
|
||||
|
||||
// The resolver is pure: recorded calls carry a time and a session, the execution
|
||||
// list carries the runs' windows, so the link is decided without any re-query.
|
||||
const source = fs.readFileSync(
|
||||
new URL('../../src/app/home/monitoring/utils/callLinks.ts', import.meta.url),
|
||||
'utf8',
|
||||
);
|
||||
const module = { exports: {} };
|
||||
new Function(
|
||||
'exports',
|
||||
ts.transpileModule(source, {
|
||||
compilerOptions: {
|
||||
module: ts.ModuleKind.CommonJS,
|
||||
target: ts.ScriptTarget.ES2022,
|
||||
},
|
||||
}).outputText,
|
||||
)(module.exports);
|
||||
const { findCallExecution, resolveCallTarget } = module.exports;
|
||||
|
||||
const at = (iso) => new Date(iso);
|
||||
|
||||
function run(overrides) {
|
||||
return {
|
||||
source: 'agent',
|
||||
id: 'run-1',
|
||||
session_id: 'session-a',
|
||||
started_at_ms: at('2026-10-07T10:00:00Z').getTime(),
|
||||
finished_at_ms: at('2026-10-07T10:00:30Z').getTime(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function call(overrides) {
|
||||
return {
|
||||
id: 'call-1',
|
||||
timestamp: at('2026-10-07T10:00:10Z'),
|
||||
sessionId: 'session-a',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test('links a pipeline call to the execution carrying its message id', () => {
|
||||
const execution = run({
|
||||
source: 'pipeline',
|
||||
id: 'message-9',
|
||||
session_id: null,
|
||||
});
|
||||
assert.equal(
|
||||
findCallExecution(call({ messageId: 'message-9', sessionId: undefined }), [
|
||||
execution,
|
||||
]),
|
||||
execution,
|
||||
);
|
||||
});
|
||||
|
||||
test('links an agent call to the run whose window contains it', () => {
|
||||
const execution = run({});
|
||||
assert.equal(findCallExecution(call({}), [execution]), execution);
|
||||
});
|
||||
|
||||
test('prefers the run that was live when a session has consecutive runs', () => {
|
||||
const first = run({ id: 'run-1' });
|
||||
const second = run({
|
||||
id: 'run-2',
|
||||
started_at_ms: at('2026-10-07T10:05:00Z').getTime(),
|
||||
finished_at_ms: at('2026-10-07T10:05:30Z').getTime(),
|
||||
});
|
||||
assert.equal(
|
||||
findCallExecution(call({ timestamp: at('2026-10-07T10:05:10Z') }), [
|
||||
first,
|
||||
second,
|
||||
]).id,
|
||||
'run-2',
|
||||
);
|
||||
});
|
||||
|
||||
test('an open run owns every later call of its session', () => {
|
||||
const execution = run({ finished_at_ms: null });
|
||||
assert.equal(
|
||||
findCallExecution(call({ timestamp: at('2026-10-07T10:20:00Z') }), [
|
||||
execution,
|
||||
]),
|
||||
execution,
|
||||
);
|
||||
});
|
||||
|
||||
test('a call outside every window links to nothing', () => {
|
||||
const execution = run({});
|
||||
assert.equal(
|
||||
findCallExecution(call({ timestamp: at('2026-10-07T11:00:00Z') }), [
|
||||
execution,
|
||||
]),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
findCallExecution(call({ timestamp: at('2026-10-07T09:00:00Z') }), [
|
||||
execution,
|
||||
]),
|
||||
null,
|
||||
);
|
||||
assert.equal(
|
||||
findCallExecution(call({ sessionId: 'other' }), [execution]),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test('a call keeps its message target when the execution list cannot resolve it', () => {
|
||||
assert.deepEqual(
|
||||
resolveCallTarget(
|
||||
call({ messageId: 'message-9', sessionId: 'other' }),
|
||||
[run({})],
|
||||
new Set(['message-9']),
|
||||
),
|
||||
{ kind: 'message', id: 'message-9' },
|
||||
);
|
||||
// An Agent call carries its run id in the same field: an id the message list
|
||||
// does not hold must not become a jump to the message tab.
|
||||
assert.equal(
|
||||
resolveCallTarget(
|
||||
call({ messageId: 'run-9', sessionId: 'other' }),
|
||||
[run({})],
|
||||
new Set(['message-9']),
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.equal(resolveCallTarget(call({}), []), null);
|
||||
});
|
||||
Reference in New Issue
Block a user