@celilo/e2e 0.5.1 → 0.6.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.
Files changed (38) hide show
  1. package/bin/cele2e +14 -0
  2. package/bin/e2e-cache-images +57 -0
  3. package/bin/e2e-shell +36 -13
  4. package/bin/e2e-status +8 -2
  5. package/bin/e2e-up +5 -5
  6. package/completions/install-zsh-completion.sh +78 -0
  7. package/config/dns/iamtheinternet.org.zone +1 -1
  8. package/config/routing/docker-image-preload.service +14 -0
  9. package/config/routing/docker-image-preload.sh +24 -0
  10. package/config/routing/management-routes.sh +5 -2
  11. package/docker/Dockerfile.management +2 -0
  12. package/docker/Dockerfile.target-machine +20 -0
  13. package/docker/Dockerfile.target-machine-docker +11 -19
  14. package/docker/Dockerfile.target-technitium +97 -0
  15. package/netapps/authentik.netapp +0 -0
  16. package/netapps/caddy.netapp +0 -0
  17. package/netapps/greenwave.netapp +0 -0
  18. package/netapps/homebridge.netapp +0 -0
  19. package/netapps/iptables.netapp +0 -0
  20. package/netapps/knot-unbound-internal.netapp +0 -0
  21. package/netapps/namecheap.netapp +0 -0
  22. package/netapps/technitium.netapp +0 -0
  23. package/netapps/test-ansible-output.netapp +0 -0
  24. package/package.json +7 -1
  25. package/simulators/greenwave/state.ts +7 -12
  26. package/src/cli/build.ts +204 -0
  27. package/src/cli/command-registry.ts +99 -0
  28. package/src/cli/command-tree-parser.ts +52 -0
  29. package/src/cli/completion.ts +171 -0
  30. package/src/cli/index.ts +203 -0
  31. package/src/cli/scaffold.ts +109 -0
  32. package/src/container-manager.ts +16 -6
  33. package/src/docker-compose-generator.ts +12 -10
  34. package/src/network-builder.ts +21 -0
  35. package/src/parse-line.ts +136 -0
  36. package/src/runner.ts +81 -88
  37. package/src/types.ts +4 -0
  38. package/bin/cache-docker-images +0 -46
@@ -0,0 +1,203 @@
1
+ #!/usr/bin/env bun
2
+ /**
3
+ * cele2e — E2E test CLI for Celilo-deployed applications.
4
+ *
5
+ * Usage:
6
+ * cele2e run [pattern] [--keep] [--reuse] [--live] [--published]
7
+ * cele2e up [--caddy|--full-stack|--infrastructure|--custom '<json>']
8
+ * cele2e down [--keep] [--all]
9
+ * cele2e build-infra [module-dir...] [--save]
10
+ * cele2e clear-timing
11
+ * cele2e version
12
+ * cele2e shell [container]
13
+ * cele2e status
14
+ * cele2e load
15
+ * cele2e scaffold <test-name>
16
+ */
17
+
18
+ import { spawnSync } from 'node:child_process';
19
+ import { existsSync, readFileSync, rmSync } from 'node:fs';
20
+ import { join, resolve } from 'node:path';
21
+ import { runBuild } from './build';
22
+ import { runScaffold } from './scaffold';
23
+ import { generateBashCompletion, generateZshCompletion, getCompletions } from './completion';
24
+
25
+ const PKG_DIR = resolve(import.meta.dir, '../..');
26
+ const BIN_DIR = join(PKG_DIR, 'bin');
27
+
28
+ function detectTestDir(): string {
29
+ if (process.env.E2E_TEST_DIR) return process.env.E2E_TEST_DIR;
30
+ const cwd = process.cwd();
31
+ // Check e2e/tests/ first — more specific than a generic tests/ directory
32
+ if (existsSync(join(cwd, 'e2e', 'tests'))) return join(cwd, 'e2e');
33
+ if (existsSync(join(cwd, 'tests'))) return cwd;
34
+ return cwd;
35
+ }
36
+
37
+ function runScript(script: string, args: string[], env?: Record<string, string>): never {
38
+ const result = spawnSync(join(BIN_DIR, script), args, {
39
+ stdio: 'inherit',
40
+ env: { ...process.env, ...env },
41
+ });
42
+ process.exit(result.status ?? 1);
43
+ }
44
+
45
+ function printHelp(): void {
46
+ console.log(`
47
+ cele2e — E2E test CLI for Celilo-deployed applications
48
+
49
+ Usage:
50
+ cele2e <command> [options]
51
+
52
+ Commands:
53
+ run [pattern] Run e2e tests (pattern filters by test file name)
54
+ up [preset] Start interactive test network
55
+ down Tear down running network
56
+ build-infra [dirs...] Rebuild Docker images and package all modules as .netapps
57
+ clear-timing Clear saved timing history used for test ETAs
58
+ version Show cele2e version
59
+ shell [container] Shell into a running container (default: management)
60
+ status Show network status and DNS health
61
+ load Load cached Docker images from tarball
62
+ scaffold <name> Generate a new test file from template
63
+ completion <shell> Generate shell completion script (zsh|bash)
64
+
65
+ Options for \`run\`:
66
+ --keep Keep network running after tests
67
+ --reuse Reuse existing network if running
68
+ --live Use live (non-simulated) internet
69
+ --published Use published .netapp packages
70
+
71
+ Options for \`up\`:
72
+ --caddy Start with caddy machine in DMZ
73
+ --full-stack Start with caddy, IDP, and DB machines
74
+ --custom <json> Custom machine spec JSON
75
+
76
+ Options for \`down\`:
77
+ --keep Stop containers but preserve volumes
78
+ --all Also stop competing module containers
79
+
80
+ Options for \`build-infra\`:
81
+ --save Save Docker images to tarball after build
82
+ --skip-modules Skip .netapp packaging (only rebuild Docker images)
83
+
84
+ Examples:
85
+ cele2e run # run all tests
86
+ cele2e run deploy # run tests matching "deploy"
87
+ cele2e run --reuse deploy # reuse existing network
88
+ cele2e up --full-stack # interactive full-stack environment
89
+ cele2e shell caddy # debug running caddy container
90
+ cele2e build-infra # rebuild Docker images + repackage all modules
91
+ cele2e build-infra --skip-modules # rebuild Docker images only (faster)
92
+ cele2e scaffold lunacycle-auth # create tests/lunacycle-auth.test.ts
93
+ cele2e completion zsh # print zsh completion script
94
+ `);
95
+ }
96
+
97
+ const [, , command, ...args] = process.argv;
98
+ const testDir = detectTestDir();
99
+
100
+ // Handle --get-completions before routing (used by shell completion scripts)
101
+ if (command === '--get-completions') {
102
+ const words = args.slice(0, -1);
103
+ const current = Number.parseInt(args[args.length - 1] || '0', 10);
104
+ const suggestions = getCompletions(words, current);
105
+ process.stdout.write(`${suggestions.join('\n')}\n`);
106
+ process.exit(0);
107
+ }
108
+
109
+ switch (command) {
110
+ case 'run':
111
+ runScript('e2e-run', args, { E2E_TEST_DIR: testDir });
112
+
113
+ case 'up':
114
+ runScript('e2e-up', args);
115
+
116
+ case 'down':
117
+ runScript('e2e-down', args, { E2E_TEST_DIR: testDir });
118
+
119
+ case 'shell':
120
+ runScript('e2e-shell', args);
121
+
122
+ case 'status': {
123
+ const persistentFile = join(testDir, '.e2e-persistent.json');
124
+ let projectName: string | undefined;
125
+ if (existsSync(persistentFile)) {
126
+ try {
127
+ const data = JSON.parse(readFileSync(persistentFile, 'utf-8'));
128
+ projectName = data.projectName;
129
+ console.log('=== Persistent Test Network ===');
130
+ console.log(` Project: ${data.projectName}`);
131
+ console.log(` Test: ${data.testFile}`);
132
+ console.log(` Created: ${data.createdAt}`);
133
+ console.log(` Reuse: cele2e run --reuse`);
134
+ console.log(` Tear down: cele2e down`);
135
+ console.log('');
136
+ } catch { /* ignore parse errors */ }
137
+ }
138
+ runScript('e2e-status', args, projectName ? { CELILO_E2E_PROJECT: projectName } : undefined);
139
+ }
140
+
141
+ case 'load':
142
+ runScript('e2e-load', args);
143
+
144
+ case 'build-infra': {
145
+ const save = args.includes('--save');
146
+ const skipModules = args.includes('--skip-modules');
147
+ const moduleDirs = args.filter((a) => !a.startsWith('--'));
148
+ runBuild({ pkgDir: PKG_DIR, moduleDirs, save, skipModules });
149
+ break;
150
+ }
151
+
152
+ case 'clear-timing': {
153
+ const timingFile = join(testDir, '.e2e-timing.json');
154
+ if (existsSync(timingFile)) {
155
+ rmSync(timingFile);
156
+ console.log(`Cleared timing data: ${timingFile}`);
157
+ } else {
158
+ console.log(`No timing data found at ${timingFile}`);
159
+ }
160
+ break;
161
+ }
162
+
163
+ case 'scaffold': {
164
+ const name = args[0];
165
+ if (!name) {
166
+ console.error('Error: scaffold requires a test name\n cele2e scaffold <name>');
167
+ process.exit(1);
168
+ }
169
+ runScaffold({ testDir, name });
170
+ break;
171
+ }
172
+
173
+ case 'version': {
174
+ const pkg = await import('../../package.json', { with: { type: 'json' } });
175
+ console.log(`cele2e v${pkg.default.version}`);
176
+ break;
177
+ }
178
+
179
+ case 'completion': {
180
+ const shell = args[0];
181
+ if (shell === 'zsh') {
182
+ process.stdout.write(generateZshCompletion());
183
+ } else if (shell === 'bash') {
184
+ process.stdout.write(generateBashCompletion());
185
+ } else {
186
+ console.error('Usage: cele2e completion <zsh|bash>');
187
+ process.exit(1);
188
+ }
189
+ break;
190
+ }
191
+
192
+ case undefined:
193
+ case '--help':
194
+ case '-h':
195
+ case 'help':
196
+ printHelp();
197
+ break;
198
+
199
+ default:
200
+ console.error(`Unknown command: ${command}\n`);
201
+ printHelp();
202
+ process.exit(1);
203
+ }
@@ -0,0 +1,109 @@
1
+ /**
2
+ * cele2e scaffold — generate a new e2e test file from template.
3
+ */
4
+
5
+ import { existsSync, mkdirSync, writeFileSync } from 'node:fs';
6
+ import { join } from 'node:path';
7
+
8
+ const green = '\x1b[32m';
9
+ const dim = '\x1b[2m';
10
+ const reset = '\x1b[0m';
11
+
12
+ interface ScaffoldOptions {
13
+ testDir: string;
14
+ name: string;
15
+ }
16
+
17
+ function toTitleCase(kebab: string): string {
18
+ return kebab
19
+ .split('-')
20
+ .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
21
+ .join(' ');
22
+ }
23
+
24
+ function generateTestTemplate(name: string): string {
25
+ const title = toTitleCase(name);
26
+ return `/**
27
+ * ${title} E2E Test
28
+ *
29
+ * Tests that ${title} deploys and operates correctly through Celilo's infrastructure.
30
+ *
31
+ * Run: cele2e run ${name}
32
+ * Run (reuse): cele2e run --reuse ${name}
33
+ */
34
+
35
+ import { afterAll, describe, expect, test } from 'bun:test';
36
+ import { network, progress, reconnectNetwork } from '@celilo/e2e';
37
+ import type { NetworkHandle } from '@celilo/e2e/types';
38
+
39
+ const REUSE = process.argv.includes('--reuse');
40
+ const PROJECT_NAME = 'celilo-e2e-${name}';
41
+
42
+ describe('${name}', () => {
43
+ let net: NetworkHandle;
44
+ let stageError: string | null = null;
45
+
46
+ function requireStage(stage: string) {
47
+ if (stageError) {
48
+ throw new Error(\`Skipped: \${stage} — \${stageError}\`);
49
+ }
50
+ }
51
+
52
+ afterAll(async () => {
53
+ await net?.stop();
54
+ });
55
+
56
+ test('start network', async () => {
57
+ if (REUSE) {
58
+ net = await reconnectNetwork(PROJECT_NAME);
59
+ return;
60
+ }
61
+
62
+ net = await network({ projectName: PROJECT_NAME })
63
+ .dmz({ 'machine-1': '10.0.10.10' })
64
+ // .app({ 'app-1': '10.0.20.10' })
65
+ .start();
66
+ }, 120_000);
67
+
68
+ test('deploy', async () => {
69
+ requireStage('deploy');
70
+ try {
71
+ progress('importing module', 'module imported');
72
+ await net.celilo('module import /path/to/module.netapp');
73
+ progress.done();
74
+
75
+ progress('deploying', 'deployed');
76
+ await net.celilo('module deploy my-module');
77
+ progress.done();
78
+ } catch (error) {
79
+ stageError = String(error);
80
+ throw error;
81
+ }
82
+ }, 300_000);
83
+
84
+ test('verify', async () => {
85
+ requireStage('verify');
86
+ // TODO: add verification steps
87
+ // const result = await net.exec('machine-1', 'curl -s http://localhost');
88
+ // expect(result.exitCode).toBe(0);
89
+ }, 60_000);
90
+ });
91
+ `;
92
+ }
93
+
94
+ export function runScaffold(options: ScaffoldOptions): void {
95
+ const { testDir, name } = options;
96
+
97
+ const testsDir = join(testDir, 'tests');
98
+ mkdirSync(testsDir, { recursive: true });
99
+
100
+ const outPath = join(testsDir, `${name}.test.ts`);
101
+ if (existsSync(outPath)) {
102
+ console.error(`Error: ${outPath} already exists`);
103
+ process.exit(1);
104
+ }
105
+
106
+ writeFileSync(outPath, generateTestTemplate(name));
107
+ console.log(`${green}✔${reset} Created ${outPath}`);
108
+ console.log(`\n${dim}Run it with:\n cele2e run ${name}${reset}`);
109
+ }
@@ -14,11 +14,21 @@ const PACKAGE_ROOT = join(__dirname, '..');
14
14
  * Looks for apps/celilo/ as the marker.
15
15
  */
16
16
  /**
17
- * Try to find the Celilo project root by walking up from the package directory.
18
- * Returns undefined if not found — the CLI is pre-installed in Docker so this
19
- * is only needed for development (mounting local source).
17
+ * Try to find the Celilo project root.
18
+ *
19
+ * Resolution order:
20
+ * 1. CELILO_E2E_INFRA_ROOT env var — explicit path (dev) or empty string
21
+ * (force published mode). Empty string means "do not mount source".
22
+ * 2. Walk up from the package directory looking for apps/celilo/. Works
23
+ * automatically when @celilo/e2e is used directly inside the infra repo.
24
+ * 3. undefined — falls back to the baked-in npm package inside Docker.
20
25
  */
21
26
  function findCeliloRoot(): string | undefined {
27
+ const envRoot = process.env.CELILO_E2E_INFRA_ROOT;
28
+ if (envRoot !== undefined) {
29
+ return envRoot === '' ? undefined : envRoot;
30
+ }
31
+
22
32
  let dir = PACKAGE_ROOT;
23
33
  for (let i = 0; i < 10; i++) {
24
34
  if (existsSync(join(dir, 'apps', 'celilo'))) {
@@ -108,11 +118,11 @@ function dockerExecAsync(
108
118
  timeoutMs = 60_000,
109
119
  ): Promise<ExecResult> {
110
120
  return new Promise((resolve) => {
111
- // stdbuf -oL forces line-buffered stdout so output streams in real time
112
- // rather than appearing in one burst when the process's buffer flushes.
121
+ // stdbuf -oL -eL forces line-buffered stdout and stderr so Ansible play/task
122
+ // markers stream in real time rather than arriving in one burst at the end.
113
123
  const args = [
114
124
  'compose', '-f', COMPOSE_FILE, '-p', projectName,
115
- 'exec', '-T', container, 'stdbuf', '-oL', 'bash', '-c', cmd,
125
+ 'exec', '-T', container, 'stdbuf', '-oL', '-eL', 'bash', '-c', cmd,
116
126
  ];
117
127
  const child = spawn('docker', args, { cwd: composeDir });
118
128
 
@@ -306,13 +306,15 @@ export function generateTestComposeYaml(config: NetworkConfig, celiloRoot?: stri
306
306
  const zoneNetwork = machine.zone;
307
307
  const gateway = ZONE_GATEWAYS[machine.zone];
308
308
 
309
- const isAppZone = machine.zone === 'app';
310
- const dockerfile = isAppZone
311
- ? 'docker/Dockerfile.target-machine-docker'
312
- : 'docker/Dockerfile.target-machine';
309
+ const needsDocker = machine.zone === 'app' || machine.docker === true;
310
+ const dockerfile = machine.dockerfile
311
+ || (needsDocker ? 'docker/Dockerfile.target-machine-docker' : 'docker/Dockerfile.target-machine');
313
312
 
314
313
  const machineVolumes = ['ssh-keys:/ssh-keys:ro'];
315
- if (isAppZone) {
314
+
315
+ // Mount the host image cache into Docker-capable machines so the
316
+ // docker-image-preload service can load them at startup without pulling.
317
+ if (needsDocker) {
316
318
  machineVolumes.push('./docker-image-cache:/docker-image-cache:ro');
317
319
  }
318
320
 
@@ -469,13 +471,13 @@ export function generateComposeYaml(config: NetworkConfig, celiloRoot = '..'): s
469
471
  const zoneNetwork = machine.zone;
470
472
  const gateway = ZONE_GATEWAYS[machine.zone];
471
473
 
472
- const isAppZone = machine.zone === 'app';
473
- const dockerfile = isAppZone
474
- ? 'docker/Dockerfile.target-machine-docker'
475
- : 'docker/Dockerfile.target-machine';
474
+ const needsDocker = machine.zone === 'app' || machine.docker === true;
475
+ const dockerfile = machine.dockerfile
476
+ || (needsDocker ? 'docker/Dockerfile.target-machine-docker' : 'docker/Dockerfile.target-machine');
476
477
 
477
478
  const machineVolumes = ['ssh-keys:/ssh-keys:ro'];
478
- if (isAppZone) {
479
+
480
+ if (needsDocker) {
479
481
  machineVolumes.push('./docker-image-cache:/docker-image-cache:ro');
480
482
  }
481
483
 
@@ -78,6 +78,27 @@ export class NetworkBuilder {
78
78
  return this;
79
79
  }
80
80
 
81
+ /** Add internal-zone machines that need the Docker-capable image (e.g., Technitium) */
82
+ internalDocker(machines: Record<string, string>): this {
83
+ for (const [name, ip] of Object.entries(machines)) {
84
+ this.config.internalMachines.push({ name, ip, zone: 'internal', docker: true });
85
+ }
86
+ return this;
87
+ }
88
+
89
+ /** Add a Technitium DNS machine using the dedicated pre-installed image */
90
+ technitium(machines: Record<string, string>): this {
91
+ for (const [name, ip] of Object.entries(machines)) {
92
+ this.config.internalMachines.push({
93
+ name,
94
+ ip,
95
+ zone: 'internal',
96
+ dockerfile: 'docker/Dockerfile.target-technitium',
97
+ });
98
+ }
99
+ return this;
100
+ }
101
+
81
102
  withDhcpClient(): this {
82
103
  this.config.dhcpClient = true;
83
104
  return this;
@@ -0,0 +1,136 @@
1
+ /**
2
+ * Line parser for the e2e test runner.
3
+ *
4
+ * Translates celilo CLI output lines into progress display calls.
5
+ * Extracted as a separate module so it can be unit-tested without
6
+ * spinning up Docker or a full ProgressDisplay.
7
+ */
8
+
9
+ const dim = '\x1b[2m';
10
+ const green = '\x1b[32m';
11
+ const red = '\x1b[31m';
12
+ const reset = '\x1b[0m';
13
+
14
+ export interface DisplaySink {
15
+ startStep(doing: string, done: string): void;
16
+ doneStep(override?: string): void;
17
+ failStep(message: string): void;
18
+ instantEvent(message: string): void;
19
+ subEvent(message: string): void;
20
+ }
21
+
22
+ export function parseLine(line: string, display: DisplaySink): void {
23
+ // Protocol: [progress:start] doing | done
24
+ const startMatch = line.match(/\[progress:start\] (.*?) \| (.*)/);
25
+ if (startMatch) {
26
+ display.startStep(startMatch[1], startMatch[2]);
27
+ return;
28
+ }
29
+
30
+ // Protocol: [progress:done] optional message
31
+ const doneMatch = line.match(/\[progress:done\](.*)/);
32
+ if (doneMatch) {
33
+ const msg = doneMatch[1].trim();
34
+ display.doneStep(msg || undefined);
35
+ return;
36
+ }
37
+
38
+ // Protocol: [progress:fail] message
39
+ const failMatch = line.match(/\[progress:fail\] (.*)/);
40
+ if (failMatch) {
41
+ display.failStep(failMatch[1]);
42
+ return;
43
+ }
44
+
45
+ // Legacy: [progress] message (treat as instant event)
46
+ const legacyMatch = line.match(/\[progress\] (.*)/);
47
+ if (legacyMatch) {
48
+ display.instantEvent(legacyMatch[1]);
49
+ return;
50
+ }
51
+
52
+ const deployMatch = line.match(/Module '([^']+)' deployed successfully/);
53
+ if (deployMatch) {
54
+ display.subEvent(`${green}✔${reset} ${deployMatch[1]} deployed`);
55
+ return;
56
+ }
57
+
58
+ const importMatch = line.match(/Successfully imported module: (\S+)/);
59
+ if (importMatch) {
60
+ display.subEvent(`${green}✔${reset} ${importMatch[1]} imported`);
61
+ return;
62
+ }
63
+
64
+ if (line.includes('Deploying software')) {
65
+ display.subEvent('running Ansible');
66
+ return;
67
+ }
68
+ if (line.includes('Running on_install hook') || line.includes('on_install hook')) {
69
+ display.subEvent('running on_install hook');
70
+ return;
71
+ }
72
+ if (line.includes('Software deployed') || line.includes('Software deployed successfully')) {
73
+ display.subEvent(`${green}✔${reset} Ansible done`);
74
+ return;
75
+ }
76
+ if (line.includes('Infrastructure selected')) {
77
+ display.subEvent('infrastructure selected');
78
+ return;
79
+ }
80
+ if (line.includes('Terraform skipped')) {
81
+ display.subEvent('terraform skipped (existing machine)');
82
+ return;
83
+ }
84
+ if (line.includes('terraform init') || line.includes('Terraform init')) {
85
+ display.subEvent('running terraform init');
86
+ return;
87
+ }
88
+ if (line.includes('terraform apply') || line.includes('Terraform apply')) {
89
+ display.subEvent('running terraform apply');
90
+ return;
91
+ }
92
+
93
+ const retryMatch = line.match(/RETRYING.*?(\d+) retries left/);
94
+ if (retryMatch) {
95
+ display.subEvent(`health check (${retryMatch[1]} retries left)`);
96
+ return;
97
+ }
98
+
99
+ // Ansible progress markers emitted via process.stderr in deploy-ansible.ts
100
+ if (line.startsWith('[ansible:play] ')) {
101
+ display.subEvent(`${dim}▶${reset} ${line.slice(15)}`);
102
+ return;
103
+ }
104
+ if (line.startsWith('[ansible:task] ')) {
105
+ display.subEvent(` • ${line.slice(15)}`);
106
+ return;
107
+ }
108
+ if (line.startsWith('[ansible:handler] ')) {
109
+ display.subEvent(` ↺ ${line.slice(18)}`);
110
+ return;
111
+ }
112
+ if (line.startsWith('[ansible:recap] ')) {
113
+ const recap = line.slice(16);
114
+ const failed = recap.match(/failed=(\d+)/)?.[1];
115
+ const icon = failed && failed !== '0' ? `${red}✗${reset}` : `${green}✔${reset}`;
116
+ display.subEvent(`${icon} ${recap}`);
117
+ return;
118
+ }
119
+
120
+ // Hook log output: "[moduleId:hookName] message"
121
+ const hookLogMatch = line.match(/^\[[\w-]+:[\w_-]+\] (.*)/);
122
+ if (hookLogMatch) {
123
+ display.subEvent(`${dim}hook:${reset} ${hookLogMatch[1]}`);
124
+ return;
125
+ }
126
+
127
+ // Error lines
128
+ if (
129
+ line.includes('Error:') &&
130
+ !line.includes('bun test') &&
131
+ !line.includes('ipv4: Address already')
132
+ ) {
133
+ const err = line.replace(/.*Error: /, '').slice(0, 70);
134
+ display.subEvent(`${red}✗${reset} ${err}`);
135
+ }
136
+ }