lynkeus 0.3.0 → 0.5.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/lib/cases/coverage.d.ts +31 -0
- package/lib/cases/coverage.js +82 -0
- package/lib/cases/draft.d.ts +21 -0
- package/lib/cases/draft.js +53 -0
- package/lib/cases/fixtures.d.ts +11 -0
- package/lib/cases/fixtures.js +59 -8
- package/lib/cases/frames.d.ts +6 -0
- package/lib/cases/frames.js +28 -0
- package/lib/cases/nativeLogs.d.ts +14 -0
- package/lib/cases/nativeLogs.js +111 -0
- package/lib/cases/runDirs.d.ts +13 -0
- package/lib/cases/runDirs.js +48 -0
- package/lib/cases/runfile.d.ts +5 -1
- package/lib/cases/runfile.js +67 -4
- package/lib/cases/runner.d.ts +58 -1
- package/lib/cases/runner.js +322 -39
- package/lib/cases/templates.d.ts +2 -2
- package/lib/cases/templates.js +20 -2
- package/lib/cases/traces.js +4 -1
- package/lib/cli.js +27 -4
- package/lib/commands/cases.d.ts +8 -0
- package/lib/commands/cases.js +154 -25
- package/lib/commands/ci.d.ts +13 -0
- package/lib/commands/ci.js +184 -0
- package/lib/commands/coverage.d.ts +1 -0
- package/lib/commands/coverage.js +151 -0
- package/lib/commands/device.js +32 -2
- package/lib/commands/doctor.js +8 -2
- package/lib/commands/drive.js +3 -1
- package/lib/commands/fixtures.d.ts +7 -0
- package/lib/commands/fixtures.js +94 -0
- package/lib/commands/headless.d.ts +11 -0
- package/lib/commands/headless.js +189 -9
- package/lib/commands/init.d.ts +23 -0
- package/lib/commands/init.js +231 -26
- package/lib/commands/map.d.ts +2 -0
- package/lib/commands/map.js +18 -14
- package/lib/commands/os.js +43 -10
- package/lib/commands/report.d.ts +4 -0
- package/lib/commands/report.js +156 -20
- package/lib/config.d.ts +41 -0
- package/lib/config.js +30 -0
- package/lib/device/agentVersion.d.ts +7 -0
- package/lib/device/agentVersion.js +14 -0
- package/lib/device/devices.js +6 -0
- package/lib/device/driver.js +2 -2
- package/lib/knowledge/analyticsTraffic.d.ts +10 -0
- package/lib/knowledge/analyticsTraffic.js +109 -0
- package/lib/knowledge/errors.js +6 -0
- package/lib/knowledge/events.d.ts +2 -0
- package/lib/knowledge/why.d.ts +2 -0
- package/lib/knowledge/why.js +1 -1
- package/lib/mcp/server.js +45 -4
- package/lib/runtime.d.ts +9 -1
- package/lib/runtime.js +25 -3
- package/package.json +7 -4
- package/src/cases/coverage.ts +99 -0
- package/src/cases/draft.ts +75 -0
- package/src/cases/fixtures.ts +57 -8
- package/src/cases/frames.ts +30 -0
- package/src/cases/nativeLogs.ts +112 -0
- package/src/cases/runDirs.ts +48 -0
- package/src/cases/runfile.ts +64 -4
- package/src/cases/runner.ts +372 -42
- package/src/cases/templates.ts +20 -2
- package/src/cases/traces.ts +3 -1
- package/src/cli.ts +27 -4
- package/src/commands/cases.ts +165 -23
- package/src/commands/ci.ts +189 -0
- package/src/commands/coverage.ts +147 -0
- package/src/commands/device.ts +27 -2
- package/src/commands/doctor.ts +9 -2
- package/src/commands/drive.ts +3 -1
- package/src/commands/fixtures.ts +102 -0
- package/src/commands/headless.ts +197 -9
- package/src/commands/init.ts +270 -26
- package/src/commands/map.ts +20 -15
- package/src/commands/os.ts +38 -10
- package/src/commands/report.ts +162 -18
- package/src/config.ts +48 -0
- package/src/device/agentVersion.ts +17 -0
- package/src/device/devices.ts +5 -0
- package/src/device/driver.ts +2 -2
- package/src/knowledge/analyticsTraffic.ts +142 -0
- package/src/knowledge/errors.ts +6 -0
- package/src/knowledge/events.ts +2 -1
- package/src/knowledge/why.ts +3 -1
- package/src/mcp/server.ts +45 -4
- package/src/runtime.ts +24 -3
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
const isPlain = (v) => !!v && typeof v === 'object' && !Array.isArray(v);
|
|
2
|
+
const plain = (v) => (isPlain(v) ? v : undefined);
|
|
3
|
+
const list = (v) => (Array.isArray(v) ? v : v === undefined ? [] : [v]);
|
|
4
|
+
const named = (provider, items, name, props) => items.filter(isPlain).flatMap((item) => {
|
|
5
|
+
const n = name(item);
|
|
6
|
+
if (typeof n !== 'string' || !n)
|
|
7
|
+
return [];
|
|
8
|
+
const p = plain(props(item));
|
|
9
|
+
return [{ provider, name: n, ...(p ? { props: p } : {}) }];
|
|
10
|
+
});
|
|
11
|
+
const segment = (body, _path, provider) => {
|
|
12
|
+
const b = plain(body);
|
|
13
|
+
return named(provider, list(b?.batch ?? b), (i) => i.event ?? (i.type === 'screen' || i.type === 'page' ? `${i.type}: ${i.name}` : i.type), (i) => i.properties ?? i.traits);
|
|
14
|
+
};
|
|
15
|
+
const amplitude = (body) => named('amplitude', list(plain(body)?.events), (i) => i.event_type, (i) => i.event_properties);
|
|
16
|
+
const posthog = (body) => {
|
|
17
|
+
const b = plain(body);
|
|
18
|
+
return named('posthog', list(b?.batch ?? (Array.isArray(body) ? body : b)), (i) => i.event, (i) => i.properties);
|
|
19
|
+
};
|
|
20
|
+
const mixpanel = (body) => named('mixpanel', list(body), (i) => i.event ?? (i.$set ? '$set' : undefined), (i) => i.properties ?? i.$set);
|
|
21
|
+
/** Host pattern → the service, and how to read its body when there is a known shape. */
|
|
22
|
+
const SERVICES = [
|
|
23
|
+
{ host: /(^|\.)segment\.(io|com)$/, provider: 'segment', read: segment },
|
|
24
|
+
{ host: /(^|\.)amplitude\.com$/, provider: 'amplitude', read: amplitude },
|
|
25
|
+
{ host: /(^|\.)posthog\.com$/, provider: 'posthog', read: posthog },
|
|
26
|
+
{ host: /(^|\.)mixpanel\.com$/, provider: 'mixpanel', read: mixpanel },
|
|
27
|
+
{ host: /(^|\.)sentry\.io$|^sentry\./, provider: 'sentry' },
|
|
28
|
+
{ host: /(^|\.)(datadoghq\.(com|eu)|ddog-gov\.com)$/, provider: 'datadog' },
|
|
29
|
+
{ host: /(^|\.)app-measurement\.com$|^firebaselogging/, provider: 'firebase' },
|
|
30
|
+
{ host: /(^|\.)appsflyer(sdk)?\.com$/, provider: 'appsflyer' },
|
|
31
|
+
{ host: /(^|\.)branch\.io$/, provider: 'branch' },
|
|
32
|
+
{ host: /(^|\.)braze\.(com|eu)$/, provider: 'braze' },
|
|
33
|
+
{ host: /(^|\.)onesignal\.com$/, provider: 'onesignal' },
|
|
34
|
+
{ host: /(^|\.)intercom\.(io|com)$/, provider: 'intercom' },
|
|
35
|
+
{ host: /(^|\.)bugsnag\.com$/, provider: 'bugsnag' },
|
|
36
|
+
{ host: /(^|\.)adjust\.(com|net|world)$/, provider: 'adjust' },
|
|
37
|
+
{ host: /(^|\.)clevertap-prod\.com$|(^|\.)wzrkt\.com$/, provider: 'clevertap' },
|
|
38
|
+
{ host: /(^|\.)heapanalytics\.com$/, provider: 'heap' },
|
|
39
|
+
{ host: /(^|\.)statsig(api)?\.(com|net)$/, provider: 'statsig' },
|
|
40
|
+
{ host: /(^|\.)launchdarkly\.com$/, provider: 'launchdarkly' },
|
|
41
|
+
// Segment-compatible: the same batch shape.
|
|
42
|
+
{ host: /(^|\.)rudderstack\.com$|(^|\.)rudderlabs\.com$/, provider: 'rudderstack', read: segment },
|
|
43
|
+
{ host: /^(track|cdp|track-eu|cdp-eu)\.customer\.io$/, provider: 'customerio', read: segment },
|
|
44
|
+
{ host: /(^|\.)google-analytics\.com$|(^|\.)analytics\.google\.com$/, provider: 'googleanalytics' },
|
|
45
|
+
{ host: /(^|\.)googletagmanager\.com$/, provider: 'googletagmanager' },
|
|
46
|
+
{ host: /^graph\.facebook\.com$/, provider: 'meta' },
|
|
47
|
+
{ host: /(^|\.)tiktok(v)?\.com$|(^|\.)tiktokapis\.com$/, provider: 'tiktok' },
|
|
48
|
+
{ host: /(^|\.)iterable\.com$/, provider: 'iterable' },
|
|
49
|
+
{ host: /(^|\.)moengage\.com$/, provider: 'moengage' },
|
|
50
|
+
{ host: /(^|\.)kochava\.com$/, provider: 'kochava' },
|
|
51
|
+
{ host: /(^|\.)singular\.net$/, provider: 'singular' },
|
|
52
|
+
{ host: /(^|\.)emb-api\.com$|(^|\.)embrace\.io$/, provider: 'embrace' },
|
|
53
|
+
{ host: /(^|\.)instabug\.com$|(^|\.)luciq\.ai$/, provider: 'instabug' },
|
|
54
|
+
{ host: /(^|\.)smartlook\.(cloud|com)$/, provider: 'smartlook' },
|
|
55
|
+
{ host: /(^|\.)urbanairship\.com$|(^|\.)airship\.(com|eu)$|(^|\.)asnapieu\.com$/, provider: 'airship' },
|
|
56
|
+
{ host: /(^|\.)snplow\.net$|(^|\.)snowplowanalytics\.com$/, provider: 'snowplow' },
|
|
57
|
+
{ host: /(^|\.)fullstory\.com$/, provider: 'fullstory' },
|
|
58
|
+
{ host: /(^|\.)mparticle\.com$/, provider: 'mparticle' },
|
|
59
|
+
{ host: /(^|\.)pendo\.io$/, provider: 'pendo' },
|
|
60
|
+
{ host: /(^|\.)lr-ingest\.(io|com)$|(^|\.)logrocket\.(io|com)$/, provider: 'logrocket' },
|
|
61
|
+
{ host: /(^|\.)newrelic\.com$|(^|\.)nr-data\.net$/, provider: 'newrelic' },
|
|
62
|
+
{ host: /(^|\.)hotjar\.(com|io)$/, provider: 'hotjar' },
|
|
63
|
+
{ host: /(^|\.)count\.ly$/, provider: 'countly' },
|
|
64
|
+
];
|
|
65
|
+
const parse = (text) => {
|
|
66
|
+
if (!text)
|
|
67
|
+
return undefined;
|
|
68
|
+
try {
|
|
69
|
+
return JSON.parse(text);
|
|
70
|
+
}
|
|
71
|
+
catch {
|
|
72
|
+
// Mixpanel's classic endpoint sends `data=<base64 JSON>` as a form.
|
|
73
|
+
const data = /(?:^|&)data=([^&]+)/.exec(text)?.[1];
|
|
74
|
+
if (!data)
|
|
75
|
+
return undefined;
|
|
76
|
+
try {
|
|
77
|
+
const raw = decodeURIComponent(data);
|
|
78
|
+
return JSON.parse(raw.startsWith('[') || raw.startsWith('{') ? raw : Buffer.from(raw, 'base64').toString('utf8'));
|
|
79
|
+
}
|
|
80
|
+
catch {
|
|
81
|
+
return undefined;
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
/** The service a request went to, when it is one of the known ones. */
|
|
86
|
+
export const serviceOf = (url) => {
|
|
87
|
+
try {
|
|
88
|
+
const host = new URL(url).hostname.toLowerCase();
|
|
89
|
+
return SERVICES.find((s) => s.host.test(host))?.provider;
|
|
90
|
+
}
|
|
91
|
+
catch {
|
|
92
|
+
return undefined;
|
|
93
|
+
}
|
|
94
|
+
};
|
|
95
|
+
/** What a step's requests told analytics services: each body's events, or one per call to a service it cannot read. */
|
|
96
|
+
export const trafficEvents = (requests) => requests.flatMap((r) => {
|
|
97
|
+
let url;
|
|
98
|
+
try {
|
|
99
|
+
url = new URL(r.url);
|
|
100
|
+
}
|
|
101
|
+
catch {
|
|
102
|
+
return [];
|
|
103
|
+
}
|
|
104
|
+
const service = SERVICES.find((s) => s.host.test(url.hostname.toLowerCase()));
|
|
105
|
+
if (!service)
|
|
106
|
+
return [];
|
|
107
|
+
const events = service.read?.(parse(r.request?.body), url.pathname, service.provider);
|
|
108
|
+
return events?.length ? events : [{ provider: service.provider, name: `${r.method} ${url.pathname}` }];
|
|
109
|
+
});
|
package/lib/knowledge/errors.js
CHANGED
|
@@ -39,6 +39,12 @@ const RULES = [
|
|
|
39
39
|
cause: (m) => `nothing is listening on ${m[1]}:${m[2]}`,
|
|
40
40
|
fix: () => `start the backend (or the lynkeus driver, if this is the agent's socket) and check the port the entry dials.`,
|
|
41
41
|
},
|
|
42
|
+
{
|
|
43
|
+
// Babel loads the project's config with require(); a plugin shipped as ESM only loads that way from Node 22.12.
|
|
44
|
+
re: /ERR_REQUIRE_ESM(?:[^\n]*?require\(\) of ES Module (\S+))?|require\(\) of ES Module (\S+)/,
|
|
45
|
+
cause: (m) => `Node ${process.versions.node} cannot require() an ES module${m[1] || m[2] ? ` (${m[1] || m[2]})` : ''}, which the babel config loads`,
|
|
46
|
+
fix: () => `run with Node 22.12 or newer (lynkeus-headless needs >=22), where require() loads ES modules.`,
|
|
47
|
+
},
|
|
42
48
|
{
|
|
43
49
|
re: /Test environment jest-environment-\w+ cannot be found|Unexpected token 'export'|SyntaxError: Cannot use import statement/,
|
|
44
50
|
cause: () => `jest is not transforming a dependency (ESM in node_modules)`,
|
|
@@ -2,9 +2,11 @@ export type AppEvent = {
|
|
|
2
2
|
name: string;
|
|
3
3
|
props?: Record<string, unknown>;
|
|
4
4
|
};
|
|
5
|
+
/** `provider`: the service the app's own list feeds, when the project says (`cases.appEvents`). */
|
|
5
6
|
export type RunEvent = AppEvent & {
|
|
6
7
|
step: number;
|
|
7
8
|
route?: string;
|
|
9
|
+
provider?: string;
|
|
8
10
|
};
|
|
9
11
|
type Drainable = {
|
|
10
12
|
command<T = unknown>(name: string, params?: unknown): Promise<T>;
|
package/lib/knowledge/why.d.ts
CHANGED
|
@@ -15,6 +15,8 @@ export type Evidence = {
|
|
|
15
15
|
text?: string;
|
|
16
16
|
route?: string;
|
|
17
17
|
};
|
|
18
|
+
/** Whether the app made any request before `requests` begins: `requests` may be only those of the failed command. */
|
|
19
|
+
fetchedBefore?: boolean;
|
|
18
20
|
};
|
|
19
21
|
export type Finding = {
|
|
20
22
|
rule: string;
|
package/lib/knowledge/why.js
CHANGED
|
@@ -128,7 +128,7 @@ export const explain = (e) => {
|
|
|
128
128
|
rule: 'in-flight',
|
|
129
129
|
message: `${e.inFlight} request(s) still in flight when the command gave up: the screen had not finished loading. A longer timeout, or wait for the request it needs.`,
|
|
130
130
|
});
|
|
131
|
-
if (done.length === 0 && e.inFlight === 0 && e.target?.route)
|
|
131
|
+
if (done.length === 0 && e.inFlight === 0 && e.target?.route && !e.fetchedBefore)
|
|
132
132
|
out.push({
|
|
133
133
|
rule: 'no-requests',
|
|
134
134
|
message: 'no request left the app while this ran: nothing was fetched. Is the user logged in, and is the app talking to the backend it should?',
|
package/lib/mcp/server.js
CHANGED
|
@@ -59,9 +59,24 @@ const argvFor = (c, input) => {
|
|
|
59
59
|
}
|
|
60
60
|
return av;
|
|
61
61
|
};
|
|
62
|
+
// What a model is told once, when it connects: the order things are done in, so "set lynkeus up" and "write me
|
|
63
|
+
// a test for this" need nothing more from the person asking.
|
|
64
|
+
const INSTRUCTIONS = `lynkeus drives a React Native app (bare or Expo) by what is on its screen, and runs cases written as readable steps.
|
|
65
|
+
|
|
66
|
+
In a project you do not know, call status first: it lists what is set up and, for what is not, the one thing that fixes it.
|
|
67
|
+
|
|
68
|
+
To set lynkeus up: call init (it installs lynkeus-agent, mounts it from the app's entry and makes the project's token; nobody sets a variable). The agent has native code, so the app must then be built again (npx react-native run-ios or run-android; npx expo run:ios or run:android, never Expo Go) with Metro restarted with --reset-cache. Do that with your shell if you have one, or ask the person to. Then status until an app is attached.
|
|
69
|
+
|
|
70
|
+
To write a test: call case_init once if the project has no cases, and read the README.md it writes for the format. Go to the screen with press, type and wait, calling screen to see what is there (press with settle: true answers with the screen once the app has settled; a screen read right after a press can still be the one it left); prefer testIDs (#id) over text. Write the case as a markdown file in the cases folder, then call case_run with that file. A failed step comes back with the screen it failed on: fix the case or report the app's bug, do not weaken the check.
|
|
71
|
+
|
|
72
|
+
To test without a simulator: call headless_init (it installs what the Node host needs and writes its entry; an Expo Router app works too), then headless_doctor for the native modules still to mock, then case_run with host. The backend the app talks to must be local: set apiUrl in lynkeus.config.json.
|
|
73
|
+
|
|
74
|
+
To share a run: case_run with out, screenshots and push uploads it and prints the report's address. That needs LYNKEUS_REPORTS_KEY in this server's environment.
|
|
75
|
+
|
|
76
|
+
For a run with everything (frames, film, requests, analytics, backend): case_run with out, screenshots, video (film needs fastsim), traces and push. When the run ends it warns about evidence it could not keep: pass that on.`;
|
|
62
77
|
export const serve = async (session, options = {}) => {
|
|
63
78
|
const { version } = createRequire(import.meta.url)('../../package.json');
|
|
64
|
-
const server = new Server({ name: 'lynkeus', version }, { capabilities: { tools: {} } });
|
|
79
|
+
const server = new Server({ name: 'lynkeus', version }, { capabilities: { tools: {} }, instructions: INSTRUCTIONS });
|
|
65
80
|
const served = commands().filter((c) => overMcp(c, !!options.full));
|
|
66
81
|
const byName = new Map(served.map((c) => [identifier(c.name), c]));
|
|
67
82
|
// So a model reading the app's screens knows their language before it starts guessing.
|
|
@@ -102,10 +117,36 @@ export const serve = async (session, options = {}) => {
|
|
|
102
117
|
const c = byName.get(request.params.name);
|
|
103
118
|
if (!c)
|
|
104
119
|
return { ...text(`✗ no tool ${request.params.name}`), isError: true };
|
|
105
|
-
const
|
|
120
|
+
const input = (request.params.arguments ?? {});
|
|
121
|
+
// An argument the tool does not take is dropped by argvFor; a run would then go on without it (every case
|
|
122
|
+
// instead of the one named), so it is refused, with what the tool does take.
|
|
123
|
+
const known = Object.keys(schemaFor(c).properties);
|
|
124
|
+
const unknown = Object.keys(input).filter((key) => !known.includes(key));
|
|
125
|
+
if (unknown.length)
|
|
126
|
+
return { ...text(`✗ ${request.params.name} takes no ${unknown.join(', ')}; it takes ${known.join(', ') || 'no arguments'}`), isError: true };
|
|
127
|
+
const r = await execute(argvFor(c, input), session.ctx);
|
|
106
128
|
if (r.error)
|
|
107
129
|
return { ...text(`✗ ${r.error}${r.why ? `\n\nwhy:\n${r.why}` : ''}`), isError: true };
|
|
108
|
-
|
|
130
|
+
const body = text(r.text || (r.json !== undefined ? JSON.stringify(r.json, null, 2) : 'ok'));
|
|
131
|
+
// A command that ended with a non-zero code (a failed case, a server that does not keep the contract) is
|
|
132
|
+
// reported as such, with everything it said, so a model does not read it as success.
|
|
133
|
+
return r.code === 0 ? body : { ...body, isError: true };
|
|
109
134
|
});
|
|
110
|
-
|
|
135
|
+
// The client going away ends the server: the session holds the driver's port open, and a server left behind
|
|
136
|
+
// keeps the next one (or a case run) from binding it.
|
|
137
|
+
const transport = new StdioServerTransport();
|
|
138
|
+
let stopping = false;
|
|
139
|
+
const stop = () => {
|
|
140
|
+
if (stopping)
|
|
141
|
+
return;
|
|
142
|
+
stopping = true;
|
|
143
|
+
void session
|
|
144
|
+
.stop()
|
|
145
|
+
.catch(() => undefined)
|
|
146
|
+
.finally(() => process.exit(0));
|
|
147
|
+
};
|
|
148
|
+
process.stdin.once('end', stop);
|
|
149
|
+
process.stdin.once('close', stop);
|
|
150
|
+
transport.onclose = stop;
|
|
151
|
+
await server.connect(transport);
|
|
111
152
|
};
|
package/lib/runtime.d.ts
CHANGED
|
@@ -8,5 +8,13 @@ export type Outcome = {
|
|
|
8
8
|
why?: string;
|
|
9
9
|
};
|
|
10
10
|
/** The screen, requests and trace after a failure, read as rules; empty when there is nothing to read. */
|
|
11
|
-
export declare const whyFor: (base: Base, expected?: Expected) => Promise<string>;
|
|
11
|
+
export declare const whyFor: (base: Base, expected?: Expected, startedAt?: number) => Promise<string>;
|
|
12
|
+
/**
|
|
13
|
+
* The requests made while the failed command ran, and a little before: a 403 from minutes ago is not why a wait
|
|
14
|
+
* failed now. Only when the app's clock reads like this machine's (a simulator, an emulator); a host on a clock of
|
|
15
|
+
* its own keeps them all.
|
|
16
|
+
*/
|
|
17
|
+
export declare const duringCommand: <R extends {
|
|
18
|
+
startedAt: number;
|
|
19
|
+
}>(requests: R[], startedAt: number | undefined, now?: number) => R[];
|
|
12
20
|
export declare const execute: (argv: string[], base: Base) => Promise<Outcome>;
|
package/lib/runtime.js
CHANGED
|
@@ -8,16 +8,37 @@ import { describeWhy, explain } from './knowledge/why.js';
|
|
|
8
8
|
import { helpFor, parseFor, resolve } from './registry.js';
|
|
9
9
|
import { expectedOf } from './targets.js';
|
|
10
10
|
/** The screen, requests and trace after a failure, read as rules; empty when there is nothing to read. */
|
|
11
|
-
export const whyFor = async (base, expected) => {
|
|
11
|
+
export const whyFor = async (base, expected, startedAt) => {
|
|
12
12
|
try {
|
|
13
13
|
const device = await base.device();
|
|
14
14
|
const [screen, requests, trace] = await Promise.all([device.screen(), device.requests(), device.trace()]);
|
|
15
|
-
|
|
15
|
+
const during = duringCommand(requests.requests, startedAt);
|
|
16
|
+
return describeWhy(explain({
|
|
17
|
+
screen,
|
|
18
|
+
requests: during.slice(-40),
|
|
19
|
+
inFlight: requests.inFlight,
|
|
20
|
+
trace: trace.events.slice(-40),
|
|
21
|
+
target: expected,
|
|
22
|
+
fetchedBefore: during.length < requests.requests.length,
|
|
23
|
+
}));
|
|
16
24
|
}
|
|
17
25
|
catch {
|
|
18
26
|
return '';
|
|
19
27
|
}
|
|
20
28
|
};
|
|
29
|
+
/**
|
|
30
|
+
* The requests made while the failed command ran, and a little before: a 403 from minutes ago is not why a wait
|
|
31
|
+
* failed now. Only when the app's clock reads like this machine's (a simulator, an emulator); a host on a clock of
|
|
32
|
+
* its own keeps them all.
|
|
33
|
+
*/
|
|
34
|
+
export const duringCommand = (requests, startedAt, now = Date.now()) => {
|
|
35
|
+
if (startedAt === undefined || requests.length === 0)
|
|
36
|
+
return requests;
|
|
37
|
+
const latest = Math.max(...requests.map((r) => r.startedAt));
|
|
38
|
+
if (Math.abs(latest - now) > 10 * 60_000)
|
|
39
|
+
return requests;
|
|
40
|
+
return requests.filter((r) => r.startedAt >= startedAt - 3000);
|
|
41
|
+
};
|
|
21
42
|
const TARGET_ARG = { press: 0, see: 0, wait: 0, swipe: 1, nav: 0, type: 1 };
|
|
22
43
|
export const execute = async (argv, base) => {
|
|
23
44
|
const found = resolve(argv);
|
|
@@ -35,6 +56,7 @@ export const execute = async (argv, base) => {
|
|
|
35
56
|
}
|
|
36
57
|
if (parsed.flags.help)
|
|
37
58
|
return { code: 0, text: helpFor(command) };
|
|
59
|
+
const startedAt = Date.now();
|
|
38
60
|
try {
|
|
39
61
|
const result = await command.run({ ...base, ...parsed });
|
|
40
62
|
if (result === undefined)
|
|
@@ -47,7 +69,7 @@ export const execute = async (argv, base) => {
|
|
|
47
69
|
const out = { code: 1, error: error instanceof Error ? error.message : String(error) };
|
|
48
70
|
const at = TARGET_ARG[command.name];
|
|
49
71
|
if (at !== undefined) {
|
|
50
|
-
const why = await whyFor(base, expectedOf(parsed.args[at]));
|
|
72
|
+
const why = await whyFor(base, expectedOf(parsed.args[at]), startedAt);
|
|
51
73
|
if (why)
|
|
52
74
|
out.why = why;
|
|
53
75
|
}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "lynkeus",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"description": "Drive a React Native app running lynkeus-agent from the terminal, a test runner or an MCP client: screens, flows, smoke, crawl, and the app's screen graph.",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": "./lib/cli.js",
|
|
@@ -35,8 +35,8 @@
|
|
|
35
35
|
},
|
|
36
36
|
"dependencies": {
|
|
37
37
|
"@modelcontextprotocol/sdk": "^1.30.0",
|
|
38
|
-
"lynkeus-client": "^0.
|
|
39
|
-
"lynkeus-protocol": "^0.
|
|
38
|
+
"lynkeus-client": "^0.5.0",
|
|
39
|
+
"lynkeus-protocol": "^0.5.0",
|
|
40
40
|
"pngjs": "^7.0.0",
|
|
41
41
|
"ts-morph": "^24.0.0",
|
|
42
42
|
"yaml": "^2"
|
|
@@ -47,5 +47,8 @@
|
|
|
47
47
|
"tsx": "^4.19.2",
|
|
48
48
|
"typescript": "^6.0.3"
|
|
49
49
|
},
|
|
50
|
-
"homepage": "https://github.com/lynkeushq/lynkeus#readme"
|
|
50
|
+
"homepage": "https://github.com/lynkeushq/lynkeus#readme",
|
|
51
|
+
"optionalDependencies": {
|
|
52
|
+
"sharp": "^0.34.4"
|
|
53
|
+
}
|
|
51
54
|
}
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
import fs from 'node:fs';
|
|
2
|
+
import path from 'node:path';
|
|
3
|
+
|
|
4
|
+
import type { Affected } from '../knowledge/query.js';
|
|
5
|
+
import type { Case, Step } from './format.js';
|
|
6
|
+
|
|
7
|
+
const ROUTE = /^[A-Z][\w.]+$/;
|
|
8
|
+
|
|
9
|
+
// A step names a route when it goes there, waits for it or checks it is on screen.
|
|
10
|
+
const routeOf = (step: Step): string | undefined => {
|
|
11
|
+
const v = step.value;
|
|
12
|
+
const named = (x: unknown) => (typeof x === 'string' && ROUTE.test(x) ? x : undefined);
|
|
13
|
+
if (step.verb === 'app.nav' || step.verb === 'app.wait')
|
|
14
|
+
return (
|
|
15
|
+
named(v) ??
|
|
16
|
+
(v && typeof v === 'object' && !Array.isArray(v) ? named((v as Record<string, unknown>).route ?? (v as Record<string, unknown>).what) : undefined)
|
|
17
|
+
);
|
|
18
|
+
if (step.verb === 'app.see') return v && typeof v === 'object' && !Array.isArray(v) ? named((v as Record<string, unknown>).route) : undefined;
|
|
19
|
+
return undefined;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
/** The routes a case names in its steps. */
|
|
23
|
+
export const routesNamed = (c: Case): string[] => [...new Set([...c.setup, ...c.steps].map(routeOf).filter((r): r is string => !!r))];
|
|
24
|
+
|
|
25
|
+
/** What `case run --seen <dir>` kept: each passing case's screens, by case id. */
|
|
26
|
+
export const readSeen = (dir: string | undefined): Record<string, string[]> => {
|
|
27
|
+
if (!dir || !fs.existsSync(dir)) return {};
|
|
28
|
+
const seen: Record<string, string[]> = {};
|
|
29
|
+
for (const file of fs.readdirSync(dir).filter((f) => f.endsWith('.json'))) {
|
|
30
|
+
try {
|
|
31
|
+
const s = JSON.parse(fs.readFileSync(path.join(dir, file), 'utf8')) as { id?: string; routes?: unknown };
|
|
32
|
+
if (typeof s.id === 'string' && Array.isArray(s.routes)) seen[s.id] = s.routes.filter((r): r is string => typeof r === 'string');
|
|
33
|
+
} catch {}
|
|
34
|
+
}
|
|
35
|
+
return seen;
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
export type Coverage = {
|
|
39
|
+
routes: number;
|
|
40
|
+
covered: string[];
|
|
41
|
+
uncovered: string[];
|
|
42
|
+
/** Routes a case names that the graph does not know: renamed, or a typo. */
|
|
43
|
+
unknown: string[];
|
|
44
|
+
byRoute: Record<string, string[]>;
|
|
45
|
+
files: Record<string, string>;
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
/** Which cases touch which of the app's routes, by what they name and what they went through last time they passed. */
|
|
49
|
+
export const coverageOf = (cases: Case[], graphRoutes: string[], seen: Record<string, string[]> = {}): Coverage => {
|
|
50
|
+
const byRoute: Record<string, string[]> = {};
|
|
51
|
+
const files: Record<string, string> = {};
|
|
52
|
+
const add = (route: string, id: string) => {
|
|
53
|
+
byRoute[route] ??= [];
|
|
54
|
+
if (!byRoute[route].includes(id)) byRoute[route].push(id);
|
|
55
|
+
};
|
|
56
|
+
for (const c of cases) {
|
|
57
|
+
files[c.id] = c.file;
|
|
58
|
+
for (const r of routesNamed(c)) add(r, c.id);
|
|
59
|
+
for (const r of seen[c.id] ?? []) add(r, c.id);
|
|
60
|
+
}
|
|
61
|
+
const known = new Set(graphRoutes);
|
|
62
|
+
const covered = graphRoutes.filter((r) => byRoute[r]?.length);
|
|
63
|
+
return {
|
|
64
|
+
routes: graphRoutes.length,
|
|
65
|
+
covered,
|
|
66
|
+
uncovered: graphRoutes.filter((r) => !byRoute[r]?.length),
|
|
67
|
+
unknown: Object.keys(byRoute)
|
|
68
|
+
.filter((r) => !known.has(r))
|
|
69
|
+
.sort(),
|
|
70
|
+
byRoute,
|
|
71
|
+
files,
|
|
72
|
+
};
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
export type Impact = {
|
|
76
|
+
/** Each case to run, with the reached routes it touches, nearest first. */
|
|
77
|
+
cases: { id: string; file: string; routes: string[]; distance: number; via: string }[];
|
|
78
|
+
/** Routes the change reaches that no case touches. */
|
|
79
|
+
untested: string[];
|
|
80
|
+
};
|
|
81
|
+
|
|
82
|
+
/** The cases a change reaches: the screens it touches or imports, crossed with what each case covers. */
|
|
83
|
+
export const impactOf = (affected: Affected[], coverage: Coverage): Impact => {
|
|
84
|
+
const nearest = new Map<string, Affected>();
|
|
85
|
+
for (const a of affected) if (!nearest.has(a.route) || nearest.get(a.route)!.distance > a.distance) nearest.set(a.route, a);
|
|
86
|
+
const byCase = new Map<string, Impact['cases'][number]>();
|
|
87
|
+
const untested: string[] = [];
|
|
88
|
+
for (const [route, hit] of nearest) {
|
|
89
|
+
const ids = coverage.byRoute[route] ?? [];
|
|
90
|
+
if (!ids.length) untested.push(route);
|
|
91
|
+
for (const id of ids) {
|
|
92
|
+
const c = byCase.get(id) ?? { id, file: coverage.files[id] ?? '', routes: [], distance: hit.distance, via: hit.via };
|
|
93
|
+
c.routes.push(route);
|
|
94
|
+
if (hit.distance < c.distance) Object.assign(c, { distance: hit.distance, via: hit.via });
|
|
95
|
+
byCase.set(id, c);
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
return { cases: [...byCase.values()].sort((a, b) => a.distance - b.distance || a.id.localeCompare(b.id)), untested: untested.sort() };
|
|
99
|
+
};
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { FixtureCommand } from 'lynkeus-protocol';
|
|
2
|
+
|
|
3
|
+
import type { PlanStep } from '../knowledge/plan.js';
|
|
4
|
+
|
|
5
|
+
export type DraftInput = {
|
|
6
|
+
id: string;
|
|
7
|
+
title?: string;
|
|
8
|
+
from: string;
|
|
9
|
+
to: string;
|
|
10
|
+
/** The way from `from` to `to`, as `map plan` finds it; undefined when no path is known. */
|
|
11
|
+
path: PlanStep[] | undefined;
|
|
12
|
+
/** What the project's fixtures server says it can do, offered as the setup to pick from. */
|
|
13
|
+
fixtures?: FixtureCommand[];
|
|
14
|
+
/** The project's macros (`app.login`…), offered too. */
|
|
15
|
+
macros?: string[];
|
|
16
|
+
/** The project declares a fixtures server that did not describe itself. */
|
|
17
|
+
fixturesSilent?: boolean;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
const quote = (s: string) => JSON.stringify(s);
|
|
21
|
+
|
|
22
|
+
const paramsHint = (c: FixtureCommand) => {
|
|
23
|
+
const params = Object.entries(c.params ?? {}).map(([k, p]) => `${k}${p.values ? ` (${p.values.join('|')})` : ''}`);
|
|
24
|
+
return params.length ? ` — ${params.join(', ')}` : '';
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
/**
|
|
28
|
+
* A case to finish: its frontmatter with the questions a reader needs answered, the backend's setup offered (never
|
|
29
|
+
* chosen: what state the case needs is the author's call), the way to the screen, and the checks left to write.
|
|
30
|
+
*/
|
|
31
|
+
export const draftCase = (d: DraftInput): string => {
|
|
32
|
+
const lines = [
|
|
33
|
+
'---',
|
|
34
|
+
`id: ${d.id}`,
|
|
35
|
+
`title: ${quote(d.title ?? `[What a person can do on ${d.to}]`)}`,
|
|
36
|
+
'description: >',
|
|
37
|
+
' [What this case proves, in a sentence or two.]',
|
|
38
|
+
'why: >',
|
|
39
|
+
' [What breaks for a person if it fails.]',
|
|
40
|
+
'given: >',
|
|
41
|
+
` [Who the user is and what is true when it starts, on ${d.from}.]`,
|
|
42
|
+
'---',
|
|
43
|
+
'setup:',
|
|
44
|
+
];
|
|
45
|
+
if (d.fixtures?.length) {
|
|
46
|
+
lines.push(' # Pick what the case needs; the backend answers these (lynkeus fixtures describe):');
|
|
47
|
+
for (const c of d.fixtures) {
|
|
48
|
+
const example = c.example ? ` ${JSON.stringify(c.example).replace(/"(\w+)":/g, '$1: ')}` : ' {}';
|
|
49
|
+
lines.push(` # - ${c.name}:${example}${c.summary ? ` # ${c.summary}` : ''}${paramsHint(c)}`);
|
|
50
|
+
}
|
|
51
|
+
} else if (d.fixturesSilent)
|
|
52
|
+
lines.push(' # The fixtures server did not answer lynkeus.describe (lynkeus fixtures check says why): the setup options are not listed.');
|
|
53
|
+
else lines.push(' # The state the case needs, through the project’s fixtures (cases.fixtures).');
|
|
54
|
+
lines.push('', 'steps:');
|
|
55
|
+
if (d.macros?.length) lines.push(` # The project's own steps: ${d.macros.join(', ')}`);
|
|
56
|
+
if (!d.path) {
|
|
57
|
+
lines.push(` # No known way from ${d.from} to ${d.to}: \`lynkeus crawl --to ${d.to}\` learns one.`);
|
|
58
|
+
lines.push(` - app.nav: ${d.to}`);
|
|
59
|
+
} else {
|
|
60
|
+
for (const s of d.path) {
|
|
61
|
+
if (s.press) lines.push(` - app.press: ${quote(`#${s.press}`)}${s.source === 'static' ? ' # from the source: confirm it on screen' : ''}`);
|
|
62
|
+
else lines.push(` # ${s.from} → ${s.to}: ${s.hint ?? 'find the control that leads here'}`);
|
|
63
|
+
lines.push(` - app.wait: { what: ${s.opens ? quote(s.opens) : s.to}, timeoutMs: 10000 }`);
|
|
64
|
+
}
|
|
65
|
+
}
|
|
66
|
+
lines.push(
|
|
67
|
+
` - app.see: { route: ${d.to} }`,
|
|
68
|
+
' # [What the person does here, and what must be on screen after.]',
|
|
69
|
+
'',
|
|
70
|
+
'expected: |',
|
|
71
|
+
' [What must be true when it ends, on screen and in the backend.]',
|
|
72
|
+
'',
|
|
73
|
+
);
|
|
74
|
+
return lines.join('\n');
|
|
75
|
+
};
|
package/src/cases/fixtures.ts
CHANGED
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
*
|
|
14
14
|
* {"command": "lynkeus.describe", "params": {}, "env": {}}
|
|
15
15
|
*
|
|
16
|
-
* — and a server that knows answers `{"commands": ["create_user", …]}
|
|
16
|
+
* — and a server that knows answers `{"commands": ["create_user", …]}`, each entry a name or a description
|
|
17
|
+
* (`FixtureCommand` in lynkeus-protocol: summary, params, example). The contract is docs/FIXTURES.md. With
|
|
17
18
|
* that, a case naming a fixture nobody answers is refused before anything
|
|
18
19
|
* runs. A server that answers `"ok": false`, as one written before this did,
|
|
19
20
|
* is taken to describe nothing, and its cases run unchecked. A server that is
|
|
@@ -26,9 +27,12 @@
|
|
|
26
27
|
* answer anything.
|
|
27
28
|
*/
|
|
28
29
|
import { type ChildProcessWithoutNullStreams, spawn } from 'node:child_process';
|
|
30
|
+
import fs from 'node:fs';
|
|
29
31
|
import path from 'node:path';
|
|
30
32
|
import readline from 'node:readline';
|
|
31
33
|
|
|
34
|
+
import type { FixtureCommand } from 'lynkeus-protocol';
|
|
35
|
+
|
|
32
36
|
export type FixturesConfig = { command: string; cwd?: string; env?: Record<string, string>; timeoutMs?: number };
|
|
33
37
|
|
|
34
38
|
export type Answer = Record<string, unknown>;
|
|
@@ -36,7 +40,23 @@ export type Answer = Record<string, unknown>;
|
|
|
36
40
|
/** The one command that is lynkeus's own and not the project's. */
|
|
37
41
|
export const DESCRIBE = 'lynkeus.describe';
|
|
38
42
|
|
|
39
|
-
|
|
43
|
+
/** What the server answers: the names, with each command's description where it gave one. */
|
|
44
|
+
export type Description = { commands: string[]; details: FixtureCommand[]; down?: string; protocol?: number };
|
|
45
|
+
|
|
46
|
+
/** A description as the server sent it, names and descriptions mixed, made one shape; anything else is no description. */
|
|
47
|
+
export const readDescription = (answer: Record<string, unknown> | undefined): Description | undefined => {
|
|
48
|
+
const list = answer?.commands;
|
|
49
|
+
if (!Array.isArray(list)) return undefined;
|
|
50
|
+
const details: FixtureCommand[] = [];
|
|
51
|
+
for (const c of list) {
|
|
52
|
+
if (typeof c === 'string' && c) details.push({ name: c });
|
|
53
|
+
else if (c && typeof c === 'object' && typeof (c as FixtureCommand).name === 'string' && (c as FixtureCommand).name) details.push(c as FixtureCommand);
|
|
54
|
+
else return undefined;
|
|
55
|
+
}
|
|
56
|
+
const down = typeof answer?.down === 'string' && answer.down ? answer.down : undefined;
|
|
57
|
+
const protocol = typeof answer?.protocol === 'number' ? answer.protocol : undefined;
|
|
58
|
+
return { commands: details.map((d) => d.name), details, ...(down ? { down } : {}), ...(protocol !== undefined ? { protocol } : {}) };
|
|
59
|
+
};
|
|
40
60
|
|
|
41
61
|
/** lynkeus's second own command: this connection works ahead of the cases. */
|
|
42
62
|
export const AHEAD = 'lynkeus.ahead';
|
|
@@ -48,6 +68,18 @@ const envOf = (params: Record<string, unknown>): Record<string, string> =>
|
|
|
48
68
|
.map(([k, v]) => [k.replace(/([a-z])([A-Z])/g, '$1_$2').toUpperCase(), typeof v === 'object' ? JSON.stringify(v) : String(v)]),
|
|
49
69
|
);
|
|
50
70
|
|
|
71
|
+
const describedFile = (root: string) => path.join(root, '.lynkeus', 'fixtures-described.json');
|
|
72
|
+
|
|
73
|
+
/** What the server last said it answers, and how long ago; undefined when it never did for this command. */
|
|
74
|
+
export const lastDescription = (root: string, command: string): { at: number; description: Description } | undefined => {
|
|
75
|
+
try {
|
|
76
|
+
const kept = JSON.parse(fs.readFileSync(describedFile(root), 'utf8')) as { at: number; command: string; description: Description };
|
|
77
|
+
return kept.command === command ? { at: kept.at, description: kept.description } : undefined;
|
|
78
|
+
} catch {
|
|
79
|
+
return undefined;
|
|
80
|
+
}
|
|
81
|
+
};
|
|
82
|
+
|
|
51
83
|
export class Fixtures {
|
|
52
84
|
#config: FixturesConfig;
|
|
53
85
|
#root: string;
|
|
@@ -55,6 +87,10 @@ export class Fixtures {
|
|
|
55
87
|
#lines: string[] = [];
|
|
56
88
|
#waiting: ((line: string | null) => void) | null = null;
|
|
57
89
|
#stderr = '';
|
|
90
|
+
// Ends in a row without one answer. A command that cannot start (a wrong path, a backend that is not
|
|
91
|
+
// there) would otherwise be started again for every case, and each case would wait out its own start.
|
|
92
|
+
#silentEnds = 0;
|
|
93
|
+
#broken: string | null = null;
|
|
58
94
|
|
|
59
95
|
constructor(config: FixturesConfig, root: string) {
|
|
60
96
|
this.#config = config;
|
|
@@ -70,12 +106,15 @@ export class Fixtures {
|
|
|
70
106
|
cwd: path.resolve(this.#root, this.#config.cwd ?? '.'),
|
|
71
107
|
env: { ...process.env, ...this.#config.env },
|
|
72
108
|
});
|
|
109
|
+
// Writing to a command that already ended fails here; its close says so to whoever waits.
|
|
110
|
+
child.stdin.on('error', () => undefined);
|
|
73
111
|
child.stderr.on('data', (chunk: Buffer) => {
|
|
74
112
|
this.#stderr = (this.#stderr + chunk.toString()).slice(-4000);
|
|
75
113
|
});
|
|
76
114
|
const reader = readline.createInterface({ input: child.stdout });
|
|
77
115
|
reader.on('line', (line) => {
|
|
78
116
|
if (!line.trim().startsWith('{')) return;
|
|
117
|
+
this.#silentEnds = 0;
|
|
79
118
|
if (this.#waiting) {
|
|
80
119
|
const resolve = this.#waiting;
|
|
81
120
|
this.#waiting = null;
|
|
@@ -83,6 +122,7 @@ export class Fixtures {
|
|
|
83
122
|
} else this.#lines.push(line);
|
|
84
123
|
});
|
|
85
124
|
child.on('close', () => {
|
|
125
|
+
if (this.#child === child) this.#silentEnds += 1;
|
|
86
126
|
this.#child = null;
|
|
87
127
|
this.#waiting?.(null);
|
|
88
128
|
this.#waiting = null;
|
|
@@ -92,6 +132,7 @@ export class Fixtures {
|
|
|
92
132
|
}
|
|
93
133
|
|
|
94
134
|
async call(command: string, params: Record<string, unknown>): Promise<Answer> {
|
|
135
|
+
if (this.#broken) throw new Error(`${command}: ${this.#broken}`);
|
|
95
136
|
const child = this.#start();
|
|
96
137
|
child.stdin.write(`${JSON.stringify({ command, params, env: envOf(params) })}\n`);
|
|
97
138
|
const timeoutMs = this.#config.timeoutMs ?? 120_000;
|
|
@@ -107,8 +148,11 @@ export class Fixtures {
|
|
|
107
148
|
resolve(answer);
|
|
108
149
|
};
|
|
109
150
|
});
|
|
110
|
-
if (line === null)
|
|
111
|
-
|
|
151
|
+
if (line === null) {
|
|
152
|
+
const ended = `the fixtures command ended${this.#stderr ? `: ${this.#stderr.trim().split('\n').slice(-3).join(' · ')}` : ''}`;
|
|
153
|
+
if (this.#silentEnds >= 2) this.#broken = `${ended} (it ended twice without answering, so it is not started again in this run)`;
|
|
154
|
+
throw new Error(`${command}: ${ended}`);
|
|
155
|
+
}
|
|
112
156
|
const answer = JSON.parse(line) as Answer;
|
|
113
157
|
if (answer.ok === false) throw new Error(`${command}: ${String(answer.error ?? answer.message ?? 'ok=false')}`);
|
|
114
158
|
return answer;
|
|
@@ -116,10 +160,15 @@ export class Fixtures {
|
|
|
116
160
|
|
|
117
161
|
/** What the server says it answers, or nothing when it does not say. */
|
|
118
162
|
async describe(): Promise<Description | undefined> {
|
|
119
|
-
const
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
163
|
+
const d = readDescription(await this.call(DESCRIBE, {}).catch(() => undefined));
|
|
164
|
+
// Kept for `fixtures describe`: a server that boots a backend's framework takes longer to start than an MCP
|
|
165
|
+
// client waits, and what it answers changes only with its code.
|
|
166
|
+
if (d && !d.down)
|
|
167
|
+
try {
|
|
168
|
+
fs.mkdirSync(path.dirname(describedFile(this.#root)), { recursive: true });
|
|
169
|
+
fs.writeFileSync(describedFile(this.#root), JSON.stringify({ at: Date.now(), command: this.#config.command, description: d }));
|
|
170
|
+
} catch {}
|
|
171
|
+
return d;
|
|
123
172
|
}
|
|
124
173
|
|
|
125
174
|
/** Says this connection works ahead of the cases being run. Whatever the server answers, it goes on. */
|