lynkeus-client 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md ADDED
@@ -0,0 +1,18 @@
1
+ # lynkeus-client
2
+
3
+ Node driver for apps running [lynkeus-agent](../agent).
4
+
5
+ ```ts
6
+ import { AgentServer } from 'lynkeus-client';
7
+
8
+ const agent = new AgentServer({ port: 8123 });
9
+ await agent.listen();
10
+ await agent.waitForApp();
11
+
12
+ await agent.call('press', { testId: 'get-started-button' });
13
+ await agent.call('waitFor', { route: 'Auth.Login' });
14
+ console.log((await agent.call('screen')).elements.length);
15
+ ```
16
+
17
+ `call` is typed for the core methods and open for app commands
18
+ (`agent.call('reset')`). See [docs/PROTOCOL.md](../../docs/PROTOCOL.md).
package/lib/index.d.ts ADDED
@@ -0,0 +1,35 @@
1
+ /**
2
+ * The driver end of the link. The app dials in and keeps redialing, so the
3
+ * server is the stable side: start it once, and every app launch reattaches.
4
+ */
5
+ import { EventEmitter } from 'node:events';
6
+ import type { CoreMethods, HelloEvent } from 'lynkeus-protocol';
7
+ export type { AgentEvent, CoreMethods, Element, HelloEvent, Screen, Target, RequestRecord } from 'lynkeus-protocol';
8
+ export type AgentServerOptions = {
9
+ port?: number;
10
+ host?: string;
11
+ /** Must match the app's `token`; sent as the first message. */
12
+ token?: string;
13
+ /** How long in-flight calls wait for the app to reconnect before failing (default 2500 ms). */
14
+ reconnectGraceMs?: number;
15
+ };
16
+ export declare class AgentError extends Error {
17
+ code?: string;
18
+ constructor(message: string, code?: string);
19
+ }
20
+ export declare class AgentServer extends EventEmitter {
21
+ #private;
22
+ readonly port: number;
23
+ readonly host: string;
24
+ constructor(options?: AgentServerOptions);
25
+ get connected(): boolean;
26
+ /** What the app said about itself when it attached. */
27
+ get hello(): HelloEvent | null;
28
+ listen(): Promise<void>;
29
+ close(): Promise<void>;
30
+ waitForApp(timeoutMs?: number): Promise<HelloEvent>;
31
+ /** A core method, typed. */
32
+ call<M extends keyof CoreMethods>(method: M, params?: CoreMethods[M]['params'], timeoutMs?: number): Promise<CoreMethods[M]['result']>;
33
+ /** An app command registered with `qa.register`. */
34
+ call<T = unknown>(method: string, params?: unknown, timeoutMs?: number): Promise<T>;
35
+ }
package/lib/index.js ADDED
@@ -0,0 +1,157 @@
1
+ /**
2
+ * The driver end of the link. The app dials in and keeps redialing, so the
3
+ * server is the stable side: start it once, and every app launch reattaches.
4
+ */
5
+ import { EventEmitter } from 'node:events';
6
+ import { WebSocket, WebSocketServer } from 'ws';
7
+ export class AgentError extends Error {
8
+ code;
9
+ constructor(message, code) {
10
+ super(message);
11
+ this.code = code;
12
+ }
13
+ }
14
+ export class AgentServer extends EventEmitter {
15
+ port;
16
+ host;
17
+ #token;
18
+ #session = Math.random().toString(36).slice(2) + Date.now().toString(36);
19
+ #server = null;
20
+ #socket = null;
21
+ #pending = new Map();
22
+ #nextId = 1;
23
+ #hello = null;
24
+ #graceTimer = null;
25
+ #reconnectGraceMs;
26
+ constructor(options = {}) {
27
+ super();
28
+ this.port = options.port ?? 8123;
29
+ this.host = options.host ?? '127.0.0.1';
30
+ this.#token = options.token;
31
+ this.#reconnectGraceMs = options.reconnectGraceMs ?? 2500;
32
+ }
33
+ get connected() {
34
+ return this.#socket?.readyState === WebSocket.OPEN && this.#hello !== null;
35
+ }
36
+ /** What the app said about itself when it attached. */
37
+ get hello() {
38
+ return this.#hello;
39
+ }
40
+ async listen() {
41
+ if (this.#server)
42
+ return;
43
+ const server = new WebSocketServer({ port: this.port, host: this.host });
44
+ this.#server = server;
45
+ server.on('connection', (socket) => this.#attach(socket));
46
+ await new Promise((resolve, reject) => {
47
+ server.once('listening', () => resolve());
48
+ server.once('error', reject);
49
+ });
50
+ }
51
+ async close() {
52
+ this.#cancelGrace();
53
+ this.#failPending(new AgentError('Agent server closed'));
54
+ this.#socket?.close();
55
+ this.#socket = null;
56
+ this.#hello = null;
57
+ await new Promise((resolve) => (this.#server ? this.#server.close(() => resolve()) : resolve()));
58
+ this.#server = null;
59
+ }
60
+ async waitForApp(timeoutMs = 15_000) {
61
+ if (this.connected && this.#hello)
62
+ return this.#hello;
63
+ return new Promise((resolve, reject) => {
64
+ const timer = setTimeout(() => {
65
+ this.off('hello', onHello);
66
+ reject(new AgentError(`No app attached on ${this.host}:${this.port} within ${timeoutMs}ms. Is a dev build running with <Lynkeus>?`));
67
+ }, timeoutMs);
68
+ const onHello = (hello) => {
69
+ clearTimeout(timer);
70
+ resolve(hello);
71
+ };
72
+ this.once('hello', onHello);
73
+ });
74
+ }
75
+ // The deadline keeps running across a reconnect: a resent request gets only the time that is left.
76
+ async call(method, params, timeoutMs = 20_000) {
77
+ const socket = this.#socket;
78
+ if (!socket || socket.readyState !== WebSocket.OPEN)
79
+ throw new AgentError('No app attached');
80
+ const id = this.#nextId++;
81
+ const request = JSON.stringify({ id, method, params });
82
+ const result = new Promise((resolve, reject) => {
83
+ const timer = setTimeout(() => {
84
+ this.#pending.delete(id);
85
+ reject(new AgentError(`${method} timed out after ${timeoutMs}ms`, 'timeout'));
86
+ }, timeoutMs);
87
+ this.#pending.set(id, { resolve, reject, timer, request });
88
+ });
89
+ socket.send(request);
90
+ return result;
91
+ }
92
+ #cancelGrace() {
93
+ if (this.#graceTimer)
94
+ clearTimeout(this.#graceTimer);
95
+ this.#graceTimer = null;
96
+ }
97
+ #failPending(error) {
98
+ for (const pending of this.#pending.values()) {
99
+ clearTimeout(pending.timer);
100
+ pending.reject(error);
101
+ }
102
+ this.#pending.clear();
103
+ }
104
+ #attach(socket) {
105
+ // A relaunch reconnects before the old socket notices; the newest wins.
106
+ this.#socket?.close();
107
+ this.#socket = socket;
108
+ this.#hello = null;
109
+ this.#cancelGrace();
110
+ socket.send(JSON.stringify({ event: 'auth', session: this.#session, token: this.#token }));
111
+ socket.on('message', (data) => {
112
+ let message;
113
+ try {
114
+ message = JSON.parse(String(data));
115
+ }
116
+ catch {
117
+ return;
118
+ }
119
+ if ('event' in message) {
120
+ if (message.event === 'hello') {
121
+ this.#hello = message;
122
+ // Resend what the previous socket may have dropped, but only once this one has spoken the
123
+ // protocol: a bare local connection could be any process.
124
+ if (socket.readyState === WebSocket.OPEN) {
125
+ for (const pending of this.#pending.values())
126
+ socket.send(pending.request);
127
+ }
128
+ }
129
+ this.emit(message.event, message);
130
+ return;
131
+ }
132
+ const pending = this.#pending.get(message.id);
133
+ if (!pending)
134
+ return;
135
+ this.#pending.delete(message.id);
136
+ clearTimeout(pending.timer);
137
+ if ('error' in message)
138
+ pending.reject(new AgentError(message.error.message, message.error.code));
139
+ else
140
+ pending.resolve(message.result);
141
+ });
142
+ socket.on('close', () => {
143
+ if (this.#socket !== socket)
144
+ return;
145
+ this.#socket = null;
146
+ this.#hello = null;
147
+ this.emit('disconnected');
148
+ // Dev builds redial within moments: hold in-flight calls for the next hello to resend.
149
+ if (this.#pending.size === 0)
150
+ return;
151
+ this.#graceTimer = setTimeout(() => {
152
+ this.#graceTimer = null;
153
+ this.#failPending(new AgentError('App disconnected mid-call', 'disconnected'));
154
+ }, this.#reconnectGraceMs);
155
+ });
156
+ }
157
+ }
package/package.json ADDED
@@ -0,0 +1,40 @@
1
+ {
2
+ "name": "lynkeus-client",
3
+ "version": "0.2.0",
4
+ "description": "Node driver for apps running lynkeus-agent: attach, read the screen, press, type, wait.",
5
+ "type": "module",
6
+ "main": "./lib/index.js",
7
+ "types": "./lib/index.d.ts",
8
+ "exports": {
9
+ ".": {
10
+ "types": "./lib/index.d.ts",
11
+ "default": "./lib/index.js"
12
+ }
13
+ },
14
+ "files": [
15
+ "lib",
16
+ "src"
17
+ ],
18
+ "scripts": {
19
+ "build": "tsc -p tsconfig.json",
20
+ "typecheck": "tsc -p tsconfig.json --noEmit",
21
+ "test": "node --import tsx --test src/__tests__/*.test.ts"
22
+ },
23
+ "repository": {
24
+ "type": "git",
25
+ "url": "git+https://github.com/jonamischener/lynkeus.git",
26
+ "directory": "packages/client"
27
+ },
28
+ "license": "MIT",
29
+ "dependencies": {
30
+ "lynkeus-protocol": "^0.2.0",
31
+ "ws": "^8.18.0"
32
+ },
33
+ "devDependencies": {
34
+ "@types/node": "^22.10.2",
35
+ "@types/ws": "^8.18.1",
36
+ "tsx": "^4.19.2",
37
+ "typescript": "^6.0.3"
38
+ },
39
+ "homepage": "https://github.com/jonamischener/lynkeus#readme"
40
+ }
@@ -0,0 +1,93 @@
1
+ import assert from 'node:assert/strict';
2
+ import { after, test } from 'node:test';
3
+
4
+ import { WebSocket } from 'ws';
5
+
6
+ import { AgentError, AgentServer } from '../index.js';
7
+
8
+ let port = 18_500;
9
+ const servers: AgentServer[] = [];
10
+ after(async () => {
11
+ for (const s of servers) await s.close();
12
+ });
13
+
14
+ const serve = async (options: ConstructorParameters<typeof AgentServer>[0] = {}) => {
15
+ const server = new AgentServer({ port: port++, ...options });
16
+ servers.push(server);
17
+ await server.listen();
18
+ return server;
19
+ };
20
+
21
+ type Message = { event?: string; token?: string; id?: number; method?: string };
22
+
23
+ const fakeApp = async (server: AgentServer, answer: (m: Message) => unknown = () => ({ ok: true })) => {
24
+ const socket = new WebSocket(`ws://127.0.0.1:${server.port}`);
25
+ const seen: Message[] = [];
26
+ socket.on('message', (data) => {
27
+ const m = JSON.parse(String(data)) as Message;
28
+ seen.push(m);
29
+ if (m.event === 'auth') socket.send(JSON.stringify({ event: 'hello', protocolVersion: 1, platform: 'ios', native: false, commands: [] }));
30
+ else if (m.id !== undefined) {
31
+ const reply = answer(m);
32
+ if (reply !== undefined) socket.send(JSON.stringify({ id: m.id, ...(reply as object) }));
33
+ }
34
+ });
35
+ await new Promise((r) => socket.once('open', r));
36
+ return { socket, seen };
37
+ };
38
+
39
+ test('an app that dials in is sent the token first and is ready once it says hello', async () => {
40
+ const server = await serve({ token: 'secret' });
41
+ const app = await fakeApp(server);
42
+ const hello = await server.waitForApp(2000);
43
+ assert.equal(hello.platform, 'ios');
44
+ assert.equal(server.connected, true);
45
+ assert.equal(app.seen[0]?.event, 'auth');
46
+ assert.equal(app.seen[0]?.token, 'secret');
47
+ app.socket.close();
48
+ });
49
+
50
+ test('a call gets the result the app sends back, and an error as an AgentError with its code', async () => {
51
+ const server = await serve();
52
+ const app = await fakeApp(server, (m) => (m.method === 'ping' ? { result: { pong: true } } : { error: { message: 'Unknown method', code: 'unknown' } }));
53
+ await server.waitForApp(2000);
54
+ assert.deepEqual(await server.call('ping'), { pong: true });
55
+ await assert.rejects(server.call('nope'), (e: unknown) => e instanceof AgentError && e.code === 'unknown');
56
+ app.socket.close();
57
+ });
58
+
59
+ test('a call the app never answers times out with the timeout code', async () => {
60
+ const server = await serve();
61
+ const app = await fakeApp(server, () => undefined);
62
+ await server.waitForApp(2000);
63
+ await assert.rejects(server.call('screen', undefined, 100), (e: unknown) => e instanceof AgentError && e.code === 'timeout');
64
+ app.socket.close();
65
+ });
66
+
67
+ test('with no app attached, waiting says so and a call refuses at once', async () => {
68
+ const server = await serve();
69
+ await assert.rejects(server.waitForApp(50), /No app attached/);
70
+ await assert.rejects(server.call('ping'), /No app attached/);
71
+ });
72
+
73
+ test('a call in flight when the app drops is resent to the app that comes back', async () => {
74
+ const server = await serve({ reconnectGraceMs: 1000 });
75
+ const first = await fakeApp(server, () => undefined);
76
+ await server.waitForApp(2000);
77
+ const pending = server.call('screen', undefined, 3000);
78
+ await new Promise((r) => setTimeout(r, 50));
79
+ first.socket.close();
80
+ const second = await fakeApp(server, () => ({ result: { route: 'Settings.Main' } }));
81
+ assert.deepEqual(await pending, { route: 'Settings.Main' });
82
+ second.socket.close();
83
+ });
84
+
85
+ test('a call in flight fails as disconnected when the app does not come back in time', async () => {
86
+ const server = await serve({ reconnectGraceMs: 100 });
87
+ const app = await fakeApp(server, () => undefined);
88
+ await server.waitForApp(2000);
89
+ const pending = server.call('screen', undefined, 3000);
90
+ await new Promise((r) => setTimeout(r, 50));
91
+ app.socket.close();
92
+ await assert.rejects(pending, (e: unknown) => e instanceof AgentError && e.code === 'disconnected');
93
+ });
package/src/index.ts ADDED
@@ -0,0 +1,186 @@
1
+ /**
2
+ * The driver end of the link. The app dials in and keeps redialing, so the
3
+ * server is the stable side: start it once, and every app launch reattaches.
4
+ */
5
+ import { EventEmitter } from 'node:events';
6
+
7
+ import { WebSocket, WebSocketServer } from 'ws';
8
+
9
+ import type { AgentEvent, CoreMethods, HelloEvent, Response } from 'lynkeus-protocol';
10
+
11
+ export type { AgentEvent, CoreMethods, Element, HelloEvent, Screen, Target, RequestRecord } from 'lynkeus-protocol';
12
+
13
+ export type AgentServerOptions = {
14
+ port?: number;
15
+ host?: string;
16
+ /** Must match the app's `token`; sent as the first message. */
17
+ token?: string;
18
+ /** How long in-flight calls wait for the app to reconnect before failing (default 2500 ms). */
19
+ reconnectGraceMs?: number;
20
+ };
21
+
22
+ type Pending = {
23
+ resolve: (value: unknown) => void;
24
+ reject: (error: Error) => void;
25
+ timer: ReturnType<typeof setTimeout>;
26
+ request: string;
27
+ };
28
+
29
+ export class AgentError extends Error {
30
+ code?: string;
31
+ constructor(message: string, code?: string) {
32
+ super(message);
33
+ this.code = code;
34
+ }
35
+ }
36
+
37
+ export class AgentServer extends EventEmitter {
38
+ readonly port: number;
39
+ readonly host: string;
40
+
41
+ #token?: string;
42
+ #session = Math.random().toString(36).slice(2) + Date.now().toString(36);
43
+ #server: WebSocketServer | null = null;
44
+ #socket: WebSocket | null = null;
45
+ #pending = new Map<number, Pending>();
46
+ #nextId = 1;
47
+ #hello: HelloEvent | null = null;
48
+ #graceTimer: ReturnType<typeof setTimeout> | null = null;
49
+ #reconnectGraceMs: number;
50
+
51
+ constructor(options: AgentServerOptions = {}) {
52
+ super();
53
+ this.port = options.port ?? 8123;
54
+ this.host = options.host ?? '127.0.0.1';
55
+ this.#token = options.token;
56
+ this.#reconnectGraceMs = options.reconnectGraceMs ?? 2500;
57
+ }
58
+
59
+ get connected(): boolean {
60
+ return this.#socket?.readyState === WebSocket.OPEN && this.#hello !== null;
61
+ }
62
+
63
+ /** What the app said about itself when it attached. */
64
+ get hello(): HelloEvent | null {
65
+ return this.#hello;
66
+ }
67
+
68
+ async listen(): Promise<void> {
69
+ if (this.#server) return;
70
+ const server = new WebSocketServer({ port: this.port, host: this.host });
71
+ this.#server = server;
72
+ server.on('connection', (socket) => this.#attach(socket));
73
+ await new Promise<void>((resolve, reject) => {
74
+ server.once('listening', () => resolve());
75
+ server.once('error', reject);
76
+ });
77
+ }
78
+
79
+ async close(): Promise<void> {
80
+ this.#cancelGrace();
81
+ this.#failPending(new AgentError('Agent server closed'));
82
+ this.#socket?.close();
83
+ this.#socket = null;
84
+ this.#hello = null;
85
+ await new Promise<void>((resolve) => (this.#server ? this.#server.close(() => resolve()) : resolve()));
86
+ this.#server = null;
87
+ }
88
+
89
+ async waitForApp(timeoutMs = 15_000): Promise<HelloEvent> {
90
+ if (this.connected && this.#hello) return this.#hello;
91
+ return new Promise<HelloEvent>((resolve, reject) => {
92
+ const timer = setTimeout(() => {
93
+ this.off('hello', onHello);
94
+ reject(new AgentError(`No app attached on ${this.host}:${this.port} within ${timeoutMs}ms. Is a dev build running with <Lynkeus>?`));
95
+ }, timeoutMs);
96
+ const onHello = (hello: HelloEvent) => {
97
+ clearTimeout(timer);
98
+ resolve(hello);
99
+ };
100
+ this.once('hello', onHello);
101
+ });
102
+ }
103
+
104
+ /** A core method, typed. */
105
+ async call<M extends keyof CoreMethods>(method: M, params?: CoreMethods[M]['params'], timeoutMs?: number): Promise<CoreMethods[M]['result']>;
106
+ /** An app command registered with `qa.register`. */
107
+ async call<T = unknown>(method: string, params?: unknown, timeoutMs?: number): Promise<T>;
108
+ // The deadline keeps running across a reconnect: a resent request gets only the time that is left.
109
+ async call(method: string, params?: unknown, timeoutMs = 20_000): Promise<unknown> {
110
+ const socket = this.#socket;
111
+ if (!socket || socket.readyState !== WebSocket.OPEN) throw new AgentError('No app attached');
112
+ const id = this.#nextId++;
113
+ const request = JSON.stringify({ id, method, params });
114
+ const result = new Promise<unknown>((resolve, reject) => {
115
+ const timer = setTimeout(() => {
116
+ this.#pending.delete(id);
117
+ reject(new AgentError(`${method} timed out after ${timeoutMs}ms`, 'timeout'));
118
+ }, timeoutMs);
119
+ this.#pending.set(id, { resolve, reject, timer, request });
120
+ });
121
+ socket.send(request);
122
+ return result;
123
+ }
124
+
125
+ #cancelGrace(): void {
126
+ if (this.#graceTimer) clearTimeout(this.#graceTimer);
127
+ this.#graceTimer = null;
128
+ }
129
+
130
+ #failPending(error: AgentError): void {
131
+ for (const pending of this.#pending.values()) {
132
+ clearTimeout(pending.timer);
133
+ pending.reject(error);
134
+ }
135
+ this.#pending.clear();
136
+ }
137
+
138
+ #attach(socket: WebSocket): void {
139
+ // A relaunch reconnects before the old socket notices; the newest wins.
140
+ this.#socket?.close();
141
+ this.#socket = socket;
142
+ this.#hello = null;
143
+ this.#cancelGrace();
144
+ socket.send(JSON.stringify({ event: 'auth', session: this.#session, token: this.#token }));
145
+
146
+ socket.on('message', (data) => {
147
+ let message: Response | AgentEvent;
148
+ try {
149
+ message = JSON.parse(String(data));
150
+ } catch {
151
+ return;
152
+ }
153
+ if ('event' in message) {
154
+ if (message.event === 'hello') {
155
+ this.#hello = message;
156
+ // Resend what the previous socket may have dropped, but only once this one has spoken the
157
+ // protocol: a bare local connection could be any process.
158
+ if (socket.readyState === WebSocket.OPEN) {
159
+ for (const pending of this.#pending.values()) socket.send(pending.request);
160
+ }
161
+ }
162
+ this.emit(message.event, message);
163
+ return;
164
+ }
165
+ const pending = this.#pending.get(message.id);
166
+ if (!pending) return;
167
+ this.#pending.delete(message.id);
168
+ clearTimeout(pending.timer);
169
+ if ('error' in message) pending.reject(new AgentError(message.error.message, message.error.code));
170
+ else pending.resolve(message.result);
171
+ });
172
+
173
+ socket.on('close', () => {
174
+ if (this.#socket !== socket) return;
175
+ this.#socket = null;
176
+ this.#hello = null;
177
+ this.emit('disconnected');
178
+ // Dev builds redial within moments: hold in-flight calls for the next hello to resend.
179
+ if (this.#pending.size === 0) return;
180
+ this.#graceTimer = setTimeout(() => {
181
+ this.#graceTimer = null;
182
+ this.#failPending(new AgentError('App disconnected mid-call', 'disconnected'));
183
+ }, this.#reconnectGraceMs);
184
+ });
185
+ }
186
+ }