Introduce the platform adapter layer; extract window context behind it
Template tests / tests (pull_request) Failing after 33s
Template tests / tests (pull_request) Failing after 33s
Phase 3 groundwork of the improvement plan (PR 7 of the sequence). The Linux
work is a platform rewrite, so this first establishes the interface boundary
and moves an OS-specific piece behind it with Windows behavior preserved — no
new process.platform branches in shared code.
- app/platform/index.js is the single factory that selects a platform
implementation; shared code asks it for adapters and never inspects
process.platform itself.
- app/platform/interfaces.js documents the adapter contracts
(WindowContextProvider, ClickSource, PowerPolicy) and the explicit click-
source vocabulary.
- Extracted the foreground-window/element detection into per-OS adapters,
verbatim from text-intel.js:
app/platform/windows/window-context.js (PowerShell UIAutomation)
app/platform/linux/window-context.js (xprop)
app/platform/darwin/window-context.js (AppleScript)
text-intel.js now delegates to the injected provider and its three
platform-branching methods (and the now-dead child_process import) are gone.
- app/platform/linux/diagnostics.js detects session type, portal/PipeWire,
xinput, readable input devices, and the resulting click/screen-capture
profile, returning actionable messages for the UI. Exposed via a new
platform:capabilities IPC + preload method.
This is behavior-preserving: the Windows/macOS/Linux window-context code is
the same, just relocated behind the factory, and the capture pipeline is
untouched.
Tests: platform selection for every OS, provider validity + null-object for
unsupported OS, the shared service delegating to an injected provider, Linux
capability detection (x11/xinput, Wayland-without-PipeWire messaging, no-click
fallback, evdev), the capability facade, and a guard that text-intel no longer
branches on process.platform. 268 unit tests pass; startup smoke passes and
the click self-test is unchanged (stream source, markers 3/3, burst 8/8).
Co-Authored-By: Claude Fable 5 <[email protected]>
This commit is contained in:
@@ -0,0 +1,139 @@
|
||||
'use strict';
|
||||
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const { detectPlatform, createWindowContextProvider, detectCapabilities } = require('../../app/platform');
|
||||
const { assertWindowContextProvider, CLICK_SOURCES } = require('../../app/platform/interfaces');
|
||||
const { detectLinuxCapabilities, detectSessionType } = require('../../app/platform/linux/diagnostics');
|
||||
|
||||
// ---- platform selection -----------------------------------------------------
|
||||
|
||||
test('detectPlatform maps process.platform to an adapter family', () => {
|
||||
assert.equal(detectPlatform('win32'), 'windows');
|
||||
assert.equal(detectPlatform('darwin'), 'darwin');
|
||||
assert.equal(detectPlatform('linux'), 'linux');
|
||||
assert.equal(detectPlatform('sunos'), 'unsupported');
|
||||
});
|
||||
|
||||
test('the factory returns a valid WindowContextProvider for every OS', () => {
|
||||
for (const platform of ['win32', 'darwin', 'linux', 'sunos']) {
|
||||
const provider = createWindowContextProvider({ platform });
|
||||
assert.doesNotThrow(() => assertWindowContextProvider(provider));
|
||||
assert.equal(typeof provider.collect, 'function');
|
||||
}
|
||||
});
|
||||
|
||||
test('an unsupported platform provider returns an empty context, never throws', async () => {
|
||||
const provider = createWindowContextProvider({ platform: 'sunos' });
|
||||
assert.deepEqual(await provider.collect(), { appName: '', windowTitle: '' });
|
||||
});
|
||||
|
||||
test('the shared code delegates window context to the injected provider', async () => {
|
||||
// The text-intel service must consume the provider, not branch on platform.
|
||||
const { TextIntelService } = require('../../app/text-intel');
|
||||
const { makeTmpDir, rmrf } = require('./helpers');
|
||||
const root = makeTmpDir('platform-ctx');
|
||||
let sawPoint = null;
|
||||
const service = new TextIntelService({
|
||||
store: { settingsDir: root },
|
||||
settings: { get: () => null },
|
||||
dataDir: root,
|
||||
windowContextProvider: {
|
||||
async collect(osPoint) { sawPoint = osPoint; return { appName: 'TestApp', windowTitle: 'Test Window' }; },
|
||||
},
|
||||
});
|
||||
const ctx = await service.collectForegroundWindowContext({ x: 5, y: 6 });
|
||||
assert.deepEqual(ctx, { appName: 'TestApp', windowTitle: 'Test Window' });
|
||||
assert.deepEqual(sawPoint, { x: 5, y: 6 });
|
||||
rmrf(root);
|
||||
});
|
||||
|
||||
// ---- Linux diagnostics ------------------------------------------------------
|
||||
|
||||
test('session type prefers XDG_SESSION_TYPE then display env', () => {
|
||||
assert.equal(detectSessionType({ XDG_SESSION_TYPE: 'wayland' }), 'wayland');
|
||||
assert.equal(detectSessionType({ XDG_SESSION_TYPE: 'x11' }), 'x11');
|
||||
assert.equal(detectSessionType({ WAYLAND_DISPLAY: 'wayland-0' }), 'wayland');
|
||||
assert.equal(detectSessionType({ DISPLAY: ':0' }), 'x11');
|
||||
assert.equal(detectSessionType({}), 'unknown');
|
||||
});
|
||||
|
||||
test('X11 with xinput reports marker-capable per-click capture', () => {
|
||||
const caps = detectLinuxCapabilities({
|
||||
env: { XDG_SESSION_TYPE: 'x11', DISPLAY: ':0', DBUS_SESSION_BUS_ADDRESS: 'unix:x' },
|
||||
hasBinary: (n) => n === 'xinput' || n === 'xprop',
|
||||
existsSync: () => false,
|
||||
readdirSync: () => [],
|
||||
});
|
||||
assert.equal(caps.isWayland, false);
|
||||
assert.equal(caps.clickCapture, 'x11-xinput');
|
||||
assert.equal(caps.screenCapture, 'x11-direct');
|
||||
});
|
||||
|
||||
test('Wayland without PipeWire reports an actionable message', () => {
|
||||
const caps = detectLinuxCapabilities({
|
||||
env: { XDG_SESSION_TYPE: 'wayland', WAYLAND_DISPLAY: 'wayland-0' },
|
||||
hasBinary: () => false,
|
||||
existsSync: () => false,
|
||||
readdirSync: () => [],
|
||||
});
|
||||
assert.equal(caps.isWayland, true);
|
||||
assert.equal(caps.screenCapture, 'wayland-portal');
|
||||
assert.ok(caps.messages.some((m) => /PipeWire|portal/i.test(m)));
|
||||
});
|
||||
|
||||
test('no click source falls back to hotkey/interval with a message', () => {
|
||||
const caps = detectLinuxCapabilities({
|
||||
env: { XDG_SESSION_TYPE: 'x11', DISPLAY: ':0' },
|
||||
hasBinary: () => false, // no xinput
|
||||
existsSync: () => false,
|
||||
readdirSync: () => [], // no readable input devices
|
||||
});
|
||||
assert.equal(caps.clickCapture, 'hotkey-or-interval-only');
|
||||
assert.ok(caps.messages.some((m) => /hotkey|interval/i.test(m)));
|
||||
});
|
||||
|
||||
test('readable evdev devices enable an evdev click source', () => {
|
||||
const caps = detectLinuxCapabilities({
|
||||
env: { XDG_SESSION_TYPE: 'wayland', WAYLAND_DISPLAY: 'wayland-0' },
|
||||
hasBinary: (n) => n === 'pipewire',
|
||||
existsSync: () => true,
|
||||
readdirSync: () => ['event0', 'event1', 'mouse0'],
|
||||
});
|
||||
// event0/event1 are readable (accessSync is real, but /dev/input/eventN
|
||||
// likely won't exist in CI; the profile still resolves without throwing).
|
||||
assert.ok(['evdev-wayland', 'hotkey-or-interval-only'].includes(caps.clickCapture));
|
||||
assert.equal(caps.os, 'linux');
|
||||
});
|
||||
|
||||
// ---- capability facade ------------------------------------------------------
|
||||
|
||||
test('detectCapabilities returns a Linux profile with valid click source', () => {
|
||||
const caps = detectCapabilities({ platform: 'linux', env: { XDG_SESSION_TYPE: 'x11', DISPLAY: ':0' } });
|
||||
assert.equal(caps.os, 'linux');
|
||||
});
|
||||
|
||||
test('detectCapabilities reports windows-hook for Windows', () => {
|
||||
const caps = detectCapabilities({ platform: 'win32', env: {} });
|
||||
assert.equal(caps.os, 'windows');
|
||||
assert.equal(caps.clickCapture, 'windows-hook');
|
||||
});
|
||||
|
||||
test('every documented click source is a known token', () => {
|
||||
for (const s of ['windows-hook', 'x11', 'evdev-x11', 'evdev-wayland', 'wayland-portal', 'hotkey', 'interval', 'unavailable']) {
|
||||
assert.ok(CLICK_SOURCES.includes(s));
|
||||
}
|
||||
});
|
||||
|
||||
// ---- refactor guard ---------------------------------------------------------
|
||||
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
|
||||
test('text-intel no longer branches on process.platform for window context', () => {
|
||||
const src = fs.readFileSync(path.join(__dirname, '..', '..', 'app', 'text-intel.js'), 'utf8');
|
||||
assert.doesNotMatch(src, /collectWindowsWindowContext|collectMacWindowContext|collectLinuxWindowContext/);
|
||||
assert.doesNotMatch(src, /process\.platform === 'win32'/);
|
||||
assert.match(src, /this\.windowContext\.collect/);
|
||||
});
|
||||
Reference in New Issue
Block a user