lynkeus 0.4.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 +56 -1
- package/lib/cases/runner.js +309 -33
- 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 +25 -2
- package/lib/commands/cases.d.ts +8 -0
- package/lib/commands/cases.js +102 -19
- 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/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 +69 -3
- 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 +154 -18
- package/lib/config.d.ts +25 -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/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 +16 -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 +359 -37
- package/src/cases/templates.ts +20 -2
- package/src/cases/traces.ts +3 -1
- package/src/cli.ts +25 -2
- package/src/commands/cases.ts +111 -18
- package/src/commands/ci.ts +189 -0
- package/src/commands/coverage.ts +147 -0
- package/src/commands/device.ts +27 -2
- package/src/commands/drive.ts +3 -1
- package/src/commands/fixtures.ts +102 -0
- package/src/commands/headless.ts +66 -3
- package/src/commands/map.ts +20 -15
- package/src/commands/os.ts +38 -10
- package/src/commands/report.ts +160 -16
- package/src/config.ts +15 -0
- package/src/device/agentVersion.ts +17 -0
- package/src/device/devices.ts +5 -0
- 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 +16 -4
- package/src/runtime.ts +24 -3
package/src/commands/device.ts
CHANGED
|
@@ -76,8 +76,26 @@ export const simulatorArgv = (platform: Platform, deviceId: string, sub: string,
|
|
|
76
76
|
{ bin: 'xcrun', args: ['simctl', 'launch', deviceId, app, '-AppleLanguages', `(${tag})`, '-AppleLocale', locale] },
|
|
77
77
|
];
|
|
78
78
|
}
|
|
79
|
+
case 'biometrics': {
|
|
80
|
+
// The simulator's Face ID / Touch ID enrollment, as Features ▸ Face ID ▸ Enrolled toggles it; which of the two
|
|
81
|
+
// the device has is its model's.
|
|
82
|
+
const [available] = args;
|
|
83
|
+
if (!available) throw new Error('lynkeus os biometrics --available FaceID|TouchID|none on a simulator');
|
|
84
|
+
const enrolled = available === 'none' ? '0' : '1';
|
|
85
|
+
return [
|
|
86
|
+
{ bin: 'xcrun', args: ['simctl', 'spawn', deviceId, 'notifyutil', '-s', 'com.apple.BiometricKit.enrollmentChanged', enrolled] },
|
|
87
|
+
{ bin: 'xcrun', args: ['simctl', 'spawn', deviceId, 'notifyutil', '-p', 'com.apple.BiometricKit.enrollmentChanged'] },
|
|
88
|
+
];
|
|
89
|
+
}
|
|
90
|
+
case 'appstate': {
|
|
91
|
+
// Another app in front puts this one in the background; launching it again brings it back as it was.
|
|
92
|
+
const [state] = args;
|
|
93
|
+
if (state === 'background') return [{ bin: 'xcrun', args: ['simctl', 'launch', deviceId, 'com.apple.Preferences'] }];
|
|
94
|
+
if (state === 'active') return [{ bin: 'xcrun', args: ['simctl', 'launch', deviceId, needApp()] }];
|
|
95
|
+
throw new Error('lynkeus os appstate background|active on a simulator');
|
|
96
|
+
}
|
|
79
97
|
default:
|
|
80
|
-
throw new Error(`unknown os subcommand "${sub}"; one of permissions, push, appearance, location, language`);
|
|
98
|
+
throw new Error(`unknown os subcommand "${sub}"; one of permissions, push, appearance, location, language, appstate`);
|
|
81
99
|
}
|
|
82
100
|
}
|
|
83
101
|
const adb = (a: string[]): Invocation => ({ bin: 'adb', args: ['-s', deviceId, ...a] });
|
|
@@ -99,8 +117,15 @@ export const simulatorArgv = (platform: Platform, deviceId: string, sub: string,
|
|
|
99
117
|
const [lat, lon] = where.split(',');
|
|
100
118
|
return [adb(['emu', 'geo', 'fix', lon!, lat!])];
|
|
101
119
|
}
|
|
120
|
+
case 'appstate': {
|
|
121
|
+
// Home sends the app to the background; its launcher intent brings the same task back, not a new one.
|
|
122
|
+
const [state] = args;
|
|
123
|
+
if (state === 'background') return [adb(['shell', 'input', 'keyevent', 'KEYCODE_HOME'])];
|
|
124
|
+
if (state === 'active') return [adb(['shell', 'monkey', '--pct-syskeys', '0', '-p', needApp(), '-c', 'android.intent.category.LAUNCHER', '1'])];
|
|
125
|
+
throw new Error('lynkeus os appstate background|active on an emulator or a phone');
|
|
126
|
+
}
|
|
102
127
|
default:
|
|
103
|
-
throw new Error(`"${sub}" is not available on Android through lynkeus; permissions, appearance and
|
|
128
|
+
throw new Error(`"${sub}" is not available on Android through lynkeus; permissions, appearance, location and appstate are`);
|
|
104
129
|
}
|
|
105
130
|
};
|
|
106
131
|
|
package/src/commands/drive.ts
CHANGED
|
@@ -166,7 +166,7 @@ export const driveCommands = [
|
|
|
166
166
|
define({
|
|
167
167
|
name: 'wait',
|
|
168
168
|
mcp: 'core',
|
|
169
|
-
mcpOmit: ['nth'],
|
|
169
|
+
mcpOmit: ['nth', 'since'],
|
|
170
170
|
group: 'drive',
|
|
171
171
|
summary: 'Until a route or element appears (or leaves), or a number of ms',
|
|
172
172
|
positionals: [{ name: 'what', help: `Route | route:index | ${TARGET_HELP} | 800 (ms)`, required: true }],
|
|
@@ -175,6 +175,7 @@ export const driveCommands = [
|
|
|
175
175
|
gone: { type: 'boolean', help: 'until it leaves the screen' },
|
|
176
176
|
scroll: { type: 'boolean', help: 'scroll until it is in the window' },
|
|
177
177
|
timeout: { type: 'number', help: 'give up after', default: 10000, value: 'ms' },
|
|
178
|
+
since: { type: 'number', help: 'a trace mark: a route reached after it counts, even if already left', value: 'seq' },
|
|
178
179
|
},
|
|
179
180
|
needs: 'app',
|
|
180
181
|
run: async (ctx) => {
|
|
@@ -189,6 +190,7 @@ export const driveCommands = [
|
|
|
189
190
|
gone: ctx.flags.gone ? true : undefined,
|
|
190
191
|
scroll: ctx.flags.scroll ? true : undefined,
|
|
191
192
|
timeoutMs: ctx.flags.timeout as number,
|
|
193
|
+
since: ctx.flags.since as number | undefined,
|
|
192
194
|
}),
|
|
193
195
|
};
|
|
194
196
|
},
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { FIXTURES_PROTOCOL, type FixtureCommand } from 'lynkeus-protocol';
|
|
2
|
+
|
|
3
|
+
import { DESCRIBE, type Description, Fixtures, lastDescription, readDescription } from '../cases/fixtures.js';
|
|
4
|
+
import { define } from '../registry.js';
|
|
5
|
+
|
|
6
|
+
const paramsOf = (c: FixtureCommand) =>
|
|
7
|
+
Object.entries(c.params ?? {})
|
|
8
|
+
.map(([k, p]) => `${k}${p.required ? '' : '?'}${p.values ? `=${p.values.join('|')}` : p.type ? `:${p.type}` : ''}`)
|
|
9
|
+
.join(' ');
|
|
10
|
+
|
|
11
|
+
/** What a server keeps of the contract and what it leaves out, as lines a person can act on. */
|
|
12
|
+
export const checkDescription = (d: Description | undefined): { ok: boolean; lines: string[] } => {
|
|
13
|
+
if (!d) return { ok: false, lines: ['✗ `lynkeus.describe` got no list of commands: answer {"commands": [...]} (docs/FIXTURES.md)'] };
|
|
14
|
+
const lines: string[] = [];
|
|
15
|
+
let ok = true;
|
|
16
|
+
if (d.down) {
|
|
17
|
+
ok = false;
|
|
18
|
+
lines.push(`✗ the server runs but cannot serve: ${d.down}`);
|
|
19
|
+
}
|
|
20
|
+
if (d.protocol !== undefined && d.protocol > FIXTURES_PROTOCOL) {
|
|
21
|
+
ok = false;
|
|
22
|
+
lines.push(`✗ it speaks fixtures protocol ${d.protocol}; this lynkeus knows ${FIXTURES_PROTOCOL}`);
|
|
23
|
+
}
|
|
24
|
+
const names = new Set<string>();
|
|
25
|
+
for (const c of d.details) {
|
|
26
|
+
if (names.has(c.name)) {
|
|
27
|
+
ok = false;
|
|
28
|
+
lines.push(`✗ ${c.name} is listed twice`);
|
|
29
|
+
}
|
|
30
|
+
names.add(c.name);
|
|
31
|
+
}
|
|
32
|
+
const bare = d.details.filter((c) => !c.summary);
|
|
33
|
+
lines.push(`✓ ${d.commands.length} command${d.commands.length === 1 ? '' : 's'}`);
|
|
34
|
+
if (bare.length)
|
|
35
|
+
lines.push(
|
|
36
|
+
`· ${bare.length} without a summary (${bare
|
|
37
|
+
.slice(0, 6)
|
|
38
|
+
.map((c) => c.name)
|
|
39
|
+
.join(', ')}${bare.length > 6 ? ', …' : ''}): a summary and params let authors and models pick a setup`,
|
|
40
|
+
);
|
|
41
|
+
return { ok, lines };
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
export const fixturesCommands = [
|
|
45
|
+
define({
|
|
46
|
+
name: 'fixtures describe',
|
|
47
|
+
group: 'runs',
|
|
48
|
+
summary: 'List what the project’s fixtures server can do: each command, what it makes or reads, its parameters',
|
|
49
|
+
details:
|
|
50
|
+
'Starts the command `cases.fixtures` names, asks `lynkeus.describe`, and prints each command with its summary and parameters, for whoever writes a case. A server that answers names only shows names. What it answered last (here or before a run) is kept in .lynkeus and shown for a day without starting it; --fresh asks again.',
|
|
51
|
+
flags: { fresh: { type: 'boolean', help: 'ask the server even when it described itself in the last day' } },
|
|
52
|
+
needs: 'nothing',
|
|
53
|
+
session: false,
|
|
54
|
+
mcp: 'core',
|
|
55
|
+
run: async (ctx) => {
|
|
56
|
+
const config = ctx.config.cases ?? {};
|
|
57
|
+
if (!config.fixtures) return { text: 'the project declares no fixtures (cases.fixtures in lynkeus.config.json)', code: 1 };
|
|
58
|
+
const root = config.base ?? ctx.root;
|
|
59
|
+
const kept = ctx.flags.fresh ? undefined : lastDescription(root, config.fixtures.command);
|
|
60
|
+
const recent = kept && Date.now() - kept.at < 24 * 3600_000 ? kept : undefined;
|
|
61
|
+
let d = recent?.description;
|
|
62
|
+
if (!d) {
|
|
63
|
+
const server = new Fixtures(config.fixtures, root);
|
|
64
|
+
d = await server.describe().finally(() => server.close());
|
|
65
|
+
}
|
|
66
|
+
if (!d) return { text: 'the fixtures server did not describe itself (it answers `lynkeus.describe` with {"commands": [...]})', code: 1 };
|
|
67
|
+
const text = d.details.map((c) => `${c.name}${c.params ? ` ${paramsOf(c)}` : ''}${c.summary ? `\n ${c.summary}` : ''}`).join('\n');
|
|
68
|
+
const age = recent ? `\n(as the server described itself ${Math.round((Date.now() - recent.at) / 60_000)} min ago; --fresh asks it again)` : '';
|
|
69
|
+
return { text: d.down ? `${text}\n(the server cannot serve now: ${d.down})` : `${text}${age}`, json: d };
|
|
70
|
+
},
|
|
71
|
+
}),
|
|
72
|
+
define({
|
|
73
|
+
name: 'fixtures check',
|
|
74
|
+
group: 'runs',
|
|
75
|
+
summary: 'Tell whether the project’s fixtures server keeps the fixtures protocol',
|
|
76
|
+
details:
|
|
77
|
+
'Starts the server, asks `lynkeus.describe`, and checks the answer against the contract in docs/FIXTURES.md: a list of commands, no name twice, a protocol version this lynkeus knows, and whether it is up. It changes nothing in the backend.',
|
|
78
|
+
needs: 'nothing',
|
|
79
|
+
session: false,
|
|
80
|
+
mcp: 'core',
|
|
81
|
+
run: async (ctx) => {
|
|
82
|
+
const config = ctx.config.cases ?? {};
|
|
83
|
+
if (!config.fixtures) return { text: 'the project declares no fixtures (cases.fixtures in lynkeus.config.json)', code: 1 };
|
|
84
|
+
const server = new Fixtures(config.fixtures, config.base ?? ctx.root);
|
|
85
|
+
// Its own call, not describe(): a server that never answers is told apart from one that answers wrong.
|
|
86
|
+
const answered = await server.call(DESCRIBE, {}).then(
|
|
87
|
+
(answer) => ({ answer }),
|
|
88
|
+
(error: Error) => ({ error: error.message }),
|
|
89
|
+
);
|
|
90
|
+
server.close();
|
|
91
|
+
if ('error' in answered)
|
|
92
|
+
return {
|
|
93
|
+
text: `✗ the fixtures server gave no answer to lynkeus.describe: ${answered.error}\n (is its backend up? the command is \`${config.fixtures.command}\`)`,
|
|
94
|
+
json: { ok: false, error: answered.error },
|
|
95
|
+
code: 1,
|
|
96
|
+
};
|
|
97
|
+
const d = readDescription(answered.answer);
|
|
98
|
+
const { ok, lines } = checkDescription(d);
|
|
99
|
+
return { text: lines.join('\n'), json: { ok, description: d }, code: ok ? 0 : 1 };
|
|
100
|
+
},
|
|
101
|
+
}),
|
|
102
|
+
];
|
package/src/commands/headless.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { spawn } from 'node:child_process';
|
|
2
|
+
import { createHash } from 'node:crypto';
|
|
2
3
|
import fs from 'node:fs';
|
|
4
|
+
import { createRequire } from 'node:module';
|
|
3
5
|
import path from 'node:path';
|
|
4
6
|
|
|
5
7
|
import { type LynkeusConfig, lynkeusDir, portFromEnv, readToken } from '../config.js';
|
|
@@ -36,6 +38,18 @@ const JEST_CONFIGS = ['jest.config.js', 'jest.config.cjs', 'jest.config.mjs', 'j
|
|
|
36
38
|
// React Native and most of what an app adds to it ship untranspiled ES modules, which jest leaves alone unless told.
|
|
37
39
|
const apiFromConfig = (config: LynkeusConfig): Record<string, string> => (config.apiUrl ? { [config.apiEnv ?? 'API_URL']: config.apiUrl } : {});
|
|
38
40
|
|
|
41
|
+
/**
|
|
42
|
+
* A jest cache of its own for each API base. Babel inlines `process.env` into the code it transforms and jest
|
|
43
|
+
* keeps that output, so a host started with another API address kept calling the first one it was started with.
|
|
44
|
+
*/
|
|
45
|
+
export const cacheFor = (root: string, env: Record<string, string | undefined>, apiEnv = 'API_URL'): string => {
|
|
46
|
+
const key = createHash('sha256')
|
|
47
|
+
.update(`${apiEnv}=${env[apiEnv] ?? ''}`)
|
|
48
|
+
.digest('hex')
|
|
49
|
+
.slice(0, 12);
|
|
50
|
+
return path.join(lynkeusDir(root), 'jest-cache', key);
|
|
51
|
+
};
|
|
52
|
+
|
|
39
53
|
const TRANSFORM_PACKAGES =
|
|
40
54
|
'((jest-)?react-native[^/]*|@react-native[^/]*|@react-navigation|react-navigation|expo(nent)?|@expo(nent)?/.*|expo-.*|@unimodules|unimodules|@shopify|@gorhom|@sentry/react-native|lynkeus-[^/]+|yoga-layout|@testing-library|immer|@reduxjs/toolkit|react-redux|redux|reselect|@expo-google-fonts|native-base|standard-navigation)';
|
|
41
55
|
|
|
@@ -65,6 +79,21 @@ const aliasesOf = (root: string): Record<string, string> => {
|
|
|
65
79
|
* What jest is told when the app has not said it itself, so an app with no jest set up runs headless as it is:
|
|
66
80
|
* the preset its framework needs (jest-expo for Expo, React Native's otherwise), and which packages under node_modules to transpile.
|
|
67
81
|
*/
|
|
82
|
+
/** The app's react folder, when lynkeus-headless would load another copy; undefined when they are one. */
|
|
83
|
+
export const duplicateReact = (root: string): string | undefined => {
|
|
84
|
+
const require = createRequire(import.meta.url);
|
|
85
|
+
try {
|
|
86
|
+
// Its exports leave out package.json: from its entry up to the folder that holds one.
|
|
87
|
+
let headless = path.dirname(fs.realpathSync(require.resolve('lynkeus-headless', { paths: [root] })));
|
|
88
|
+
while (!fs.existsSync(path.join(headless, 'package.json')) && path.dirname(headless) !== headless) headless = path.dirname(headless);
|
|
89
|
+
const ours = path.dirname(require.resolve('react/package.json', { paths: [root] }));
|
|
90
|
+
const theirs = path.dirname(require.resolve('react/package.json', { paths: [headless] }));
|
|
91
|
+
return fs.realpathSync(ours) === fs.realpathSync(theirs) ? undefined : ours;
|
|
92
|
+
} catch {
|
|
93
|
+
return undefined;
|
|
94
|
+
}
|
|
95
|
+
};
|
|
96
|
+
|
|
68
97
|
const jestDefaults = (root: string): string[] => {
|
|
69
98
|
const file = JEST_CONFIGS.find((f) => fs.existsSync(path.join(root, f)));
|
|
70
99
|
let config = file ? fs.readFileSync(path.join(root, file), 'utf8') : '';
|
|
@@ -75,13 +104,23 @@ const jestDefaults = (root: string): string[] => {
|
|
|
75
104
|
} catch {}
|
|
76
105
|
}
|
|
77
106
|
const flags: string[] = [];
|
|
107
|
+
const react = duplicateReact(root);
|
|
108
|
+
// lynkeus-headless with a React of its own (a linked package, a workspace, pnpm) breaks every hook: the app and
|
|
109
|
+
// the renderer must share the app's copy.
|
|
110
|
+
const sameReact = react ? { '^react$': react, '^react/(.*)$': `${react}/$1` } : {};
|
|
78
111
|
if (!config) {
|
|
79
112
|
// A style sheet imported from native code (Expo's templates import global.css) means nothing to jest, even
|
|
80
113
|
// behind an alias, so it is mapped first. Jest merges these with the preset's own mappings.
|
|
81
114
|
const stub = path.join(lynkeusDir(root), 'style-stub.js');
|
|
82
115
|
fs.mkdirSync(path.dirname(stub), { recursive: true });
|
|
83
116
|
fs.writeFileSync(stub, 'module.exports = {};\n');
|
|
84
|
-
flags.push('--preset', jestPreset(root), `--moduleNameMapper=${JSON.stringify({ '\\.(css|less|sass|scss)$': stub, ...aliasesOf(root) })}`);
|
|
117
|
+
flags.push('--preset', jestPreset(root), `--moduleNameMapper=${JSON.stringify({ '\\.(css|less|sass|scss)$': stub, ...aliasesOf(root), ...sameReact })}`);
|
|
118
|
+
} else if (react) {
|
|
119
|
+
if (config.includes('moduleNameMapper'))
|
|
120
|
+
process.stderr.write(
|
|
121
|
+
`lynkeus: lynkeus-headless finds another copy of react than the app's; add to moduleNameMapper in the app's jest config: '^react$': '${react}', '^react/(.*)$': '${react}/$1'\n`,
|
|
122
|
+
);
|
|
123
|
+
else flags.push(`--moduleNameMapper=${JSON.stringify(sameReact)}`);
|
|
85
124
|
}
|
|
86
125
|
// Two that must stay as they are, as jest-expo leaves them: babel running itself, or Reanimated's plugin, fails.
|
|
87
126
|
if (!config.includes('transformIgnorePatterns'))
|
|
@@ -190,17 +229,41 @@ export const headlessCommands = [
|
|
|
190
229
|
fs.writeFileSync(testFile, hostTest(rel.startsWith('.') ? rel : `./${rel}`, port, ctx.config, path.basename(ctx.root)));
|
|
191
230
|
// Node resolves `localhost` to ::1 first; local backends usually listen on IPv4 only.
|
|
192
231
|
const nodeOptions = [process.env.NODE_OPTIONS, '--dns-result-order=ipv4first'].filter(Boolean).join(' ');
|
|
193
|
-
|
|
232
|
+
// LYNKEUS_HEADLESS tells the app's jest setup a host is starting, not its unit tests: a setup that stubs a native
|
|
233
|
+
// SDK whole can leave it real there, so the call reaches the stand-in `nativeMocks` shows.
|
|
234
|
+
const env = { ...apiFromConfig(ctx.config), ...process.env, LYNKEUS_TOKEN: token, LYNKEUS_HEADLESS: '1', CI: '1', NODE_OPTIONS: nodeOptions };
|
|
235
|
+
const cache = `--cacheDirectory=${cacheFor(ctx.root, env, ctx.config.apiEnv)}`;
|
|
236
|
+
const child = spawn('npx', ['jest', testFile, '--runInBand', '--watchAll=false', ...jestDefaults(ctx.root), cache, ...ctx.passthrough], {
|
|
194
237
|
cwd: ctx.root,
|
|
195
238
|
stdio: ['inherit', 'inherit', 'pipe'],
|
|
196
|
-
env
|
|
239
|
+
env,
|
|
240
|
+
// Its own process group: npx, jest and the host it runs end together. Stopping only npx left the
|
|
241
|
+
// host dialing the port, where a later run's driver took it for the app it meant to drive.
|
|
242
|
+
detached: true,
|
|
243
|
+
});
|
|
244
|
+
const stop = (signal: NodeJS.Signals) => {
|
|
245
|
+
try {
|
|
246
|
+
if (child.pid) process.kill(-child.pid, signal);
|
|
247
|
+
} catch {
|
|
248
|
+
// already gone
|
|
249
|
+
}
|
|
250
|
+
};
|
|
251
|
+
const signals: NodeJS.Signals[] = ['SIGINT', 'SIGTERM', 'SIGHUP'];
|
|
252
|
+
const forward = signals.map((signal) => {
|
|
253
|
+
const handler = () => stop(signal);
|
|
254
|
+
process.on(signal, handler);
|
|
255
|
+
return () => process.off(signal, handler);
|
|
197
256
|
});
|
|
257
|
+
const onExit = () => stop('SIGTERM');
|
|
258
|
+
process.on('exit', onExit);
|
|
198
259
|
let tail = '';
|
|
199
260
|
child.stderr?.on('data', (chunk: Buffer) => {
|
|
200
261
|
process.stderr.write(chunk);
|
|
201
262
|
tail = (tail + chunk.toString()).slice(-20000);
|
|
202
263
|
});
|
|
203
264
|
const code = await new Promise<number>((resolve) => child.on('exit', (c) => resolve(c ?? 0)));
|
|
265
|
+
for (const off of forward) off();
|
|
266
|
+
process.off('exit', onExit);
|
|
204
267
|
if (code) {
|
|
205
268
|
const found = explainError(tail);
|
|
206
269
|
if (found.length) ctx.err(describeExplanations(found));
|
package/src/commands/map.ts
CHANGED
|
@@ -30,6 +30,25 @@ const describeAffected = (affected: Affected[], chain: boolean): string[] => {
|
|
|
30
30
|
return lines;
|
|
31
31
|
};
|
|
32
32
|
|
|
33
|
+
/** Source files a branch changed against `base`, committed or not. */
|
|
34
|
+
export const changedSources = (root: string, base: string, err: (line: string) => void): string[] => {
|
|
35
|
+
const git = (...args: string[]) => execFileSync('git', args, { cwd: root, encoding: 'utf8' });
|
|
36
|
+
try {
|
|
37
|
+
const names = `${git('diff', '--name-only', `${base}...HEAD`)}\n${git('diff', '--name-only', 'HEAD')}`;
|
|
38
|
+
return [
|
|
39
|
+
...new Set(
|
|
40
|
+
names
|
|
41
|
+
.split('\n')
|
|
42
|
+
.map((l) => l.trim())
|
|
43
|
+
.filter((l) => /\.[cm]?[jt]sx?$/.test(l)),
|
|
44
|
+
),
|
|
45
|
+
];
|
|
46
|
+
} catch (error) {
|
|
47
|
+
err(`could not diff against ${base}: ${error instanceof Error ? error.message : String(error)}`);
|
|
48
|
+
return [];
|
|
49
|
+
}
|
|
50
|
+
};
|
|
51
|
+
|
|
33
52
|
export const mapCommands = [
|
|
34
53
|
define({
|
|
35
54
|
name: 'map build',
|
|
@@ -215,21 +234,7 @@ export const mapCommands = [
|
|
|
215
234
|
run: async (ctx) => {
|
|
216
235
|
const graph = ctx.graph();
|
|
217
236
|
const base = ctx.flags.base as string;
|
|
218
|
-
const
|
|
219
|
-
let changed: string[] = [];
|
|
220
|
-
try {
|
|
221
|
-
const names = `${git('diff', '--name-only', `${base}...HEAD`)}\n${git('diff', '--name-only', 'HEAD')}`;
|
|
222
|
-
changed = [
|
|
223
|
-
...new Set(
|
|
224
|
-
names
|
|
225
|
-
.split('\n')
|
|
226
|
-
.map((l) => l.trim())
|
|
227
|
-
.filter((l) => /\.[cm]?[jt]sx?$/.test(l)),
|
|
228
|
-
),
|
|
229
|
-
];
|
|
230
|
-
} catch (error) {
|
|
231
|
-
ctx.err(`could not diff against ${base}: ${error instanceof Error ? error.message : String(error)}`);
|
|
232
|
-
}
|
|
237
|
+
const changed = changedSources(ctx.root, base, ctx.err);
|
|
233
238
|
const affected = screensAffectedBy(graph, changed, ctx.flags.depth as number);
|
|
234
239
|
const lines = [`changed against ${base}: ${changed.length} source file(s)`, ...describeAffected(affected, true)];
|
|
235
240
|
if (!affected.length && changed.length) lines.push(' (no screen imports these files within the depth)');
|
package/src/commands/os.ts
CHANGED
|
@@ -35,11 +35,25 @@ export const osCommands = [
|
|
|
35
35
|
define({
|
|
36
36
|
name: 'os appstate',
|
|
37
37
|
group: 'os',
|
|
38
|
-
summary:
|
|
39
|
-
|
|
38
|
+
summary:
|
|
39
|
+
'AppState listeners see the change (background, then active: what refetches or re-locks); a host answers it, a simulator or an emulator does it for real',
|
|
40
|
+
positionals: [{ name: 'state', help: 'active | background | inactive (inactive: a host only)', required: true }],
|
|
41
|
+
flags: { app: { type: 'string', help: 'the app to bring back on a simulator or emulator (default: the config’s app)', value: 'bundleId' } },
|
|
40
42
|
aliases: ['appstate'],
|
|
41
43
|
needs: 'app',
|
|
42
|
-
run:
|
|
44
|
+
run: async (ctx) => {
|
|
45
|
+
const device = await ctx.device();
|
|
46
|
+
try {
|
|
47
|
+
return { json: await device.command('appstate', { state: ctx.args[0] }) };
|
|
48
|
+
} catch (error) {
|
|
49
|
+
// A host stands in for the OS; on a simulator or an emulator the OS itself moves the app.
|
|
50
|
+
if ((await device.resolved()).id === 'headless') throw error;
|
|
51
|
+
const done = await simulator('appstate')(ctx);
|
|
52
|
+
// Back in front, the app dials in again; what comes next reads its screen.
|
|
53
|
+
if (ctx.args[0] === 'active') await device.server.waitForApp(15_000);
|
|
54
|
+
return done;
|
|
55
|
+
}
|
|
56
|
+
},
|
|
43
57
|
}),
|
|
44
58
|
define({
|
|
45
59
|
name: 'os network',
|
|
@@ -66,7 +80,7 @@ export const osCommands = [
|
|
|
66
80
|
define({
|
|
67
81
|
name: 'os biometrics',
|
|
68
82
|
group: 'os',
|
|
69
|
-
summary: '
|
|
83
|
+
summary: 'The sensor and what the next prompts answer; on a simulator, --available enrolls Face ID or Touch ID or not',
|
|
70
84
|
flags: {
|
|
71
85
|
available: { type: 'string', help: 'FaceID | TouchID | none', value: 'kind' },
|
|
72
86
|
next: { type: 'string', help: 'outcomes of the next prompts, in order', value: 'success,fail,cancel' },
|
|
@@ -74,11 +88,25 @@ export const osCommands = [
|
|
|
74
88
|
},
|
|
75
89
|
aliases: ['biometrics'],
|
|
76
90
|
needs: 'app',
|
|
77
|
-
run:
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
91
|
+
run: async (ctx) => {
|
|
92
|
+
const device = await ctx.device();
|
|
93
|
+
const params = {
|
|
94
|
+
available: ctx.flags.available || undefined,
|
|
95
|
+
next: ctx.flags.next ? (ctx.flags.next as string).split(',') : undefined,
|
|
96
|
+
fallback: ctx.flags.fallback || undefined,
|
|
97
|
+
};
|
|
98
|
+
try {
|
|
99
|
+
return { json: await device.command('biometrics', params) };
|
|
100
|
+
} catch (error) {
|
|
101
|
+
const info = await device.resolved();
|
|
102
|
+
if (info.id === 'headless') throw error;
|
|
103
|
+
// A device's prompts answer the person in front of it: only a simulator's enrollment is set from outside.
|
|
104
|
+
// Anything else stays the host's, and the runner says the step cannot run here.
|
|
105
|
+
if (info.platform !== 'ios' || !params.available || params.next || params.fallback) throw error;
|
|
106
|
+
ctx.args = [String(params.available)];
|
|
107
|
+
return simulator('biometrics')(ctx);
|
|
108
|
+
}
|
|
109
|
+
},
|
|
82
110
|
}),
|
|
83
111
|
define({
|
|
84
112
|
name: 'os scan',
|
|
@@ -107,7 +135,7 @@ export const osCommands = [
|
|
|
107
135
|
define({
|
|
108
136
|
name: 'mock',
|
|
109
137
|
group: 'os',
|
|
110
|
-
summary: '
|
|
138
|
+
summary: 'Answer a request before the network: a host stands in for it, on a device the agent answers in the app (--sticky survives a host reset)',
|
|
111
139
|
positionals: [{ name: 'route', help: "'GET /path' (a bare path matches any method)" }],
|
|
112
140
|
flags: {
|
|
113
141
|
status: { type: 'number', help: 'HTTP status to answer', default: 200, value: 'code' },
|
package/src/commands/report.ts
CHANGED
|
@@ -2,6 +2,8 @@ import fs from 'node:fs';
|
|
|
2
2
|
import path from 'node:path';
|
|
3
3
|
|
|
4
4
|
import { define } from '../registry.js';
|
|
5
|
+
import { ageOf, megabytes, pruneRuns } from '../cases/runDirs.js';
|
|
6
|
+
import { keptOrDeleted } from './cases.js';
|
|
5
7
|
|
|
6
8
|
type Fetch = typeof fetch;
|
|
7
9
|
|
|
@@ -22,11 +24,54 @@ const answer = async (response: Response, doing: string) => {
|
|
|
22
24
|
throw new Error(`${doing}: ${body.error ?? `the service answered ${response.status}`}`);
|
|
23
25
|
};
|
|
24
26
|
|
|
27
|
+
const UPLOADS_AT_ONCE = 4;
|
|
28
|
+
const CASES_PART = 256 * 1024;
|
|
29
|
+
const UPLOAD_ATTEMPTS = 3;
|
|
30
|
+
|
|
31
|
+
// A ustar header holds a name of up to 100 bytes and a folder of up to 155 before it.
|
|
32
|
+
const fitsTar = (file: string) => {
|
|
33
|
+
const cut = file.lastIndexOf('/');
|
|
34
|
+
return Buffer.byteLength(file.slice(cut + 1)) <= 100 && Buffer.byteLength(cut > 0 ? file.slice(0, cut) : '') <= 155;
|
|
35
|
+
};
|
|
36
|
+
|
|
37
|
+
/** An uncompressed tar: frames are PNG or AVIF, already compressed, and a tar is read as it arrives. */
|
|
38
|
+
export const tarOf = (entries: [string, Uint8Array][]): Uint8Array => {
|
|
39
|
+
const blocks: Uint8Array[] = [];
|
|
40
|
+
for (const [file, data] of entries) {
|
|
41
|
+
const header = Buffer.alloc(512);
|
|
42
|
+
const cut = file.lastIndexOf('/');
|
|
43
|
+
header.write(file.slice(cut + 1), 0, 'utf8');
|
|
44
|
+
header.write('0000644\0', 100);
|
|
45
|
+
header.write('0000000\0', 108);
|
|
46
|
+
header.write('0000000\0', 116);
|
|
47
|
+
header.write(`${data.length.toString(8).padStart(11, '0')}\0`, 124);
|
|
48
|
+
header.write(
|
|
49
|
+
`${Math.floor(Date.now() / 1000)
|
|
50
|
+
.toString(8)
|
|
51
|
+
.padStart(11, '0')}\0`,
|
|
52
|
+
136,
|
|
53
|
+
);
|
|
54
|
+
header.write('0', 156);
|
|
55
|
+
header.write('ustar\0', 257);
|
|
56
|
+
header.write('00', 263);
|
|
57
|
+
if (cut > 0) header.write(file.slice(0, cut), 345, 'utf8');
|
|
58
|
+
header.fill(' ', 148, 156);
|
|
59
|
+
const sum = header.reduce((n, b) => n + b, 0);
|
|
60
|
+
header.write(`${sum.toString(8).padStart(6, '0')}\0 `, 148);
|
|
61
|
+
blocks.push(header, data, Buffer.alloc((512 - (data.length % 512)) % 512));
|
|
62
|
+
}
|
|
63
|
+
blocks.push(Buffer.alloc(1024));
|
|
64
|
+
return Buffer.concat(blocks);
|
|
65
|
+
};
|
|
66
|
+
|
|
25
67
|
/**
|
|
26
68
|
* Uploads a run directory (`case run --out`) to the reports service: the run, its cases, then every frame and
|
|
27
69
|
* film the cases name. A file the organization's plan does not keep (a film on Free) is left out and listed.
|
|
28
70
|
*/
|
|
29
|
-
export const pushRun = async (
|
|
71
|
+
export const pushRun = async (
|
|
72
|
+
dir: string,
|
|
73
|
+
o: { url: string; key: string; project: string; fetch?: Fetch; onReady?: (url: string) => void; onProgress?: (line: string) => void },
|
|
74
|
+
): Promise<Pushed> => {
|
|
30
75
|
const call = o.fetch ?? fetch;
|
|
31
76
|
const auth = { authorization: `Bearer ${o.key}` };
|
|
32
77
|
const base = o.url.replace(/\/+$/, '');
|
|
@@ -42,31 +87,98 @@ export const pushRun = async (dir: string, o: { url: string; key: string; projec
|
|
|
42
87
|
'creating the run',
|
|
43
88
|
);
|
|
44
89
|
const id = String(created.id);
|
|
45
|
-
|
|
90
|
+
// A dropped connection, a 5xx or a 429 is tried again, waiting longer each time.
|
|
91
|
+
const sendWithRetries = async (target: string, body: Uint8Array, doing: string): Promise<Response> => {
|
|
92
|
+
for (let attempt = 1; ; attempt++) {
|
|
93
|
+
const response = await call(target, { method: 'PUT', headers: auth, body }).catch((error: unknown) => {
|
|
94
|
+
if (attempt >= UPLOAD_ATTEMPTS) throw new Error(`${doing}: ${error instanceof Error ? error.message : String(error)}`);
|
|
95
|
+
return undefined;
|
|
96
|
+
});
|
|
97
|
+
if (response && ((response.status < 500 && response.status !== 429) || attempt >= UPLOAD_ATTEMPTS)) return response;
|
|
98
|
+
await new Promise((resolve) => setTimeout(resolve, 500 * 2 ** (attempt - 1)));
|
|
99
|
+
}
|
|
100
|
+
};
|
|
101
|
+
// The run file in parts of about 256 KB: with every request's headers and bodies it runs to megabytes, more
|
|
102
|
+
// than the service reads in one request. A service from before parts answers without `part`, and then
|
|
103
|
+
// takes the whole file in one.
|
|
104
|
+
const parts: string[] = [];
|
|
105
|
+
for (const line of cases.split('\n').filter((l) => l.trim())) {
|
|
106
|
+
const last = parts.length - 1;
|
|
107
|
+
if (last >= 0 && Buffer.byteLength(parts[last]!) + Buffer.byteLength(line) < CASES_PART) parts[last] += `\n${line}`;
|
|
108
|
+
else parts.push(line);
|
|
109
|
+
}
|
|
110
|
+
for (const [n, part] of parts.entries()) {
|
|
111
|
+
const sent = await answer(await sendWithRetries(`${base}/v1/runs/${id}/cases?part=${n}`, Buffer.from(part), 'sending its cases'), 'sending its cases');
|
|
112
|
+
if (sent.part === undefined) {
|
|
113
|
+
await answer(await call(`${base}/v1/runs/${id}/cases`, { method: 'PUT', headers: auth, body: cases }), 'sending its cases');
|
|
114
|
+
break;
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
// The report opens as soon as its cases are in: their steps, results and requests show at once, and each
|
|
118
|
+
// frame as it arrives.
|
|
119
|
+
const finished = await answer(await call(`${base}/v1/runs/${id}/finish`, { method: 'POST', headers: auth }), 'finishing the run');
|
|
120
|
+
o.onReady?.(String(finished.url));
|
|
46
121
|
|
|
47
|
-
|
|
122
|
+
// Per case: its frames, sent as one tar, and its film, sent on its own (a film can be bigger than a request).
|
|
123
|
+
const work: { frames: string[]; film?: string }[] = [];
|
|
48
124
|
for (const line of cases.split('\n')) {
|
|
49
125
|
if (!line.trim()) continue;
|
|
50
126
|
const c = JSON.parse(line) as { video?: string; steps?: { screenshot?: string }[] };
|
|
51
|
-
|
|
127
|
+
const frames = (c.steps ?? []).map((s) => s.screenshot).filter((p): p is string => !!p && fs.existsSync(path.join(dir, p)));
|
|
128
|
+
const film = c.video && fs.existsSync(path.join(dir, c.video)) ? c.video : undefined;
|
|
129
|
+
work.push({ frames: [...new Set(frames)], ...(film ? { film } : {}) });
|
|
52
130
|
}
|
|
53
131
|
let files = 0;
|
|
54
132
|
const refused: string[] = [];
|
|
55
|
-
|
|
56
|
-
const
|
|
57
|
-
|
|
58
|
-
const response = await call(`${base}/v1/runs/${id}/files/${file.split('/').map(encodeURIComponent).join('/')}`, {
|
|
59
|
-
method: 'PUT',
|
|
60
|
-
headers: auth,
|
|
61
|
-
body: fs.readFileSync(local),
|
|
62
|
-
});
|
|
133
|
+
const sendFile = async (file: string) => {
|
|
134
|
+
const target = `${base}/v1/runs/${id}/files/${file.split('/').map(encodeURIComponent).join('/')}`;
|
|
135
|
+
const response = await sendWithRetries(target, fs.readFileSync(path.join(dir, file)), `sending ${file}`);
|
|
63
136
|
if (response.status === 402) refused.push(file);
|
|
64
137
|
else {
|
|
65
138
|
await answer(response, `sending ${file}`);
|
|
66
139
|
files += 1;
|
|
67
140
|
}
|
|
68
|
-
}
|
|
69
|
-
|
|
141
|
+
};
|
|
142
|
+
// A suite with screenshots is thousands of frames: a request per frame took one over 20 minutes to send. A
|
|
143
|
+
// service from before bundles answers 404, and the frames then go one by one, as they used to.
|
|
144
|
+
let bundles = true;
|
|
145
|
+
const sendFrames = async (frames: string[]) => {
|
|
146
|
+
const fits = frames.filter(fitsTar);
|
|
147
|
+
if (bundles && fits.length > 0) {
|
|
148
|
+
const response = await sendWithRetries(
|
|
149
|
+
`${base}/v1/runs/${id}/bundle`,
|
|
150
|
+
tarOf(fits.map((f) => [f, fs.readFileSync(path.join(dir, f))])),
|
|
151
|
+
`sending ${fits.length} frames`,
|
|
152
|
+
);
|
|
153
|
+
if (response.status === 404 || response.status === 405) bundles = false;
|
|
154
|
+
else {
|
|
155
|
+
await answer(response, `sending ${fits.length} frames`);
|
|
156
|
+
files += fits.length;
|
|
157
|
+
for (const f of frames.filter((f) => !fitsTar(f))) await sendFile(f);
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
for (const f of frames) await sendFile(f);
|
|
162
|
+
};
|
|
163
|
+
const queue = [...work];
|
|
164
|
+
const total = work.length;
|
|
165
|
+
let sent = 0;
|
|
166
|
+
let bytes = 0;
|
|
167
|
+
const started = Date.now();
|
|
168
|
+
const sizeOf = (c: { frames: string[]; film?: string }) =>
|
|
169
|
+
[...c.frames, ...(c.film ? [c.film] : [])].reduce((n, f) => n + fs.statSync(path.join(dir, f)).size, 0);
|
|
170
|
+
await Promise.all(
|
|
171
|
+
Array.from({ length: Math.min(UPLOADS_AT_ONCE, queue.length) }, async () => {
|
|
172
|
+
for (let c = queue.shift(); c !== undefined; c = queue.shift()) {
|
|
173
|
+
await sendFrames(c.frames);
|
|
174
|
+
if (c.film) await sendFile(c.film);
|
|
175
|
+
sent += 1;
|
|
176
|
+
bytes += sizeOf(c);
|
|
177
|
+
if (sent === total || sent % Math.max(1, Math.ceil(total / 10)) === 0)
|
|
178
|
+
o.onProgress?.(`frames: ${sent}/${total} cases, ${(bytes / 1024 / 1024).toFixed(1)} MB in ${((Date.now() - started) / 1000).toFixed(0)} s`);
|
|
179
|
+
}
|
|
180
|
+
}),
|
|
181
|
+
);
|
|
70
182
|
const changes = finished.changes as Pushed['changes'] | undefined;
|
|
71
183
|
return { url: String(finished.url), files, refused, ...(changes ? { changes } : {}) };
|
|
72
184
|
};
|
|
@@ -124,6 +236,34 @@ export const service = (): Service | string => {
|
|
|
124
236
|
};
|
|
125
237
|
|
|
126
238
|
export const reportCommands = [
|
|
239
|
+
define({
|
|
240
|
+
name: 'report prune',
|
|
241
|
+
group: 'runs',
|
|
242
|
+
summary: 'Delete the runs `case run --out` left in a folder that ended more than a while ago',
|
|
243
|
+
details:
|
|
244
|
+
'Looks at the folder given, or each folder right under it, and deletes the ones that hold a run (`run.json` and `cases.jsonl`) older than --older. Nothing else is touched. Pushed reports stay on the service; this only frees the disk.',
|
|
245
|
+
positionals: [{ name: 'dir', help: 'a run folder, or the folder that holds them' }],
|
|
246
|
+
flags: {
|
|
247
|
+
older: { type: 'string', help: 'how old a run must be: 7d, 12h, 30m', value: 'age', default: '7d' },
|
|
248
|
+
'dry-run': { type: 'boolean', help: 'list what would go, delete nothing' },
|
|
249
|
+
},
|
|
250
|
+
needs: 'nothing',
|
|
251
|
+
session: false,
|
|
252
|
+
mcp: false,
|
|
253
|
+
run: async (ctx) => {
|
|
254
|
+
const root = path.resolve(ctx.args[0] ?? '.');
|
|
255
|
+
if (!fs.existsSync(root)) return { text: `${ctx.args[0]}: no such folder`, code: 2 };
|
|
256
|
+
const older = ageOf(String(ctx.flags.older ?? '7d'));
|
|
257
|
+
if (older === undefined) return { text: `--older ${ctx.flags.older}: give it as 7d, 12h or 30m`, code: 2 };
|
|
258
|
+
const dry = !!ctx.flags['dry-run'];
|
|
259
|
+
const { removed, bytes } = pruneRuns(root, older, dry);
|
|
260
|
+
if (removed.length === 0) return { text: 'no run old enough to delete', json: { removed, bytes } };
|
|
261
|
+
return {
|
|
262
|
+
text: [...removed.map((d) => `${dry ? 'would delete' : 'deleted'} ${d}`), `${dry ? 'would free' : 'freed'} ${megabytes(bytes)}`].join('\n'),
|
|
263
|
+
json: { removed, bytes },
|
|
264
|
+
};
|
|
265
|
+
},
|
|
266
|
+
}),
|
|
127
267
|
define({
|
|
128
268
|
name: 'report push',
|
|
129
269
|
group: 'runs',
|
|
@@ -145,8 +285,12 @@ export const reportCommands = [
|
|
|
145
285
|
if (typeof to === 'string') return { text: to, code: 2 };
|
|
146
286
|
const project = typeof ctx.flags.project === 'string' ? ctx.flags.project : path.basename(ctx.root);
|
|
147
287
|
try {
|
|
148
|
-
const pushed = await pushRun(dir, { ...to, project });
|
|
149
|
-
const notes = [
|
|
288
|
+
const pushed = await pushRun(dir, { ...to, project, onReady: (url) => ctx.err(`report: ${url} (its frames are on their way)`), onProgress: ctx.err });
|
|
289
|
+
const notes = [
|
|
290
|
+
...describeChanges(pushed.changes),
|
|
291
|
+
...(pushed.refused.length ? [`not kept on this plan: ${pushed.refused.join(', ')}`] : []),
|
|
292
|
+
keptOrDeleted(dir, ctx.config.report?.afterPush === 'delete' ? 'delete' : 'keep', pushed.refused.length === 0),
|
|
293
|
+
];
|
|
150
294
|
return { text: [pushed.url, ...notes].join('\n'), json: pushed };
|
|
151
295
|
} catch (error) {
|
|
152
296
|
return { text: (error as Error).message, code: 1 };
|