/** * Line parser for the e2e test runner. * * Translates celilo CLI output lines into progress display calls. * Extracted as a separate module so it can be unit-tested without * spinning up Docker or a full ProgressDisplay. */ const dim = '\x1b[2m'; const green = '\x1b[32m'; const red = '\x1b[31m'; const reset = '\x1b[0m'; export interface DisplaySink { startStep(doing: string, done: string): void; pushStep(doing: string, done: string): void; doneStep(override?: string): void; failStep(message: string): void; instantEvent(message: string): void; subEvent(message: string): void; } export interface ParseLineOptions { /** * Show hook log lines that were emitted via the `[moduleId:hookName] msg` * fallback (i.e. the in-Docker celilo had no display attached). When * false (the default for `cele2e run` without `--verbose`), these * lines are dropped — they're a chatty per-line stream meant for * debugging, and they bypass the `[progress:sub]` protocol so they * don't get collapsed under their parent step. */ verbose?: boolean; } export function parseLine( line: string, display: DisplaySink, options: ParseLineOptions = {}, ): void { // Protocol: [progress:start] doing | done const startMatch = line.match(/\[progress:start\] (.*?) \| (.*)/); if (startMatch) { display.startStep(startMatch[1], startMatch[2]); return; } // Protocol: [progress:push] doing | done — nested step under current top const pushMatch = line.match(/\[progress:push\] (.*?) \| (.*)/); if (pushMatch) { display.pushStep(pushMatch[1], pushMatch[2]); return; } // Protocol: [progress:done] optional message const doneMatch = line.match(/\[progress:done\](.*)/); if (doneMatch) { const msg = doneMatch[1].trim(); display.doneStep(msg || undefined); return; } // Protocol: [progress:fail] message const failMatch = line.match(/\[progress:fail\] (.*)/); if (failMatch) { display.failStep(failMatch[1]); return; } // Protocol: [progress:sub] message (sub-event under the current step) const subMatch = line.match(/\[progress:sub\] (.*)/); if (subMatch) { display.subEvent(subMatch[1]); return; } // Legacy: [progress] message (treat as instant event) const legacyMatch = line.match(/\[progress\] (.*)/); if (legacyMatch) { display.instantEvent(legacyMatch[1]); return; } const deployMatch = line.match(/Module '([^']+)' deployed successfully/); if (deployMatch) { display.subEvent(`${green}✔${reset} ${deployMatch[1]} deployed`); return; } const importMatch = line.match(/Successfully imported module: (\S+)/); if (importMatch) { display.subEvent(`${green}✔${reset} ${importMatch[1]} imported`); return; } if (line.includes('Deploying software')) { display.subEvent('running Ansible'); return; } if (line.includes('Running on_install hook') || line.includes('on_install hook')) { display.subEvent('running on_install hook'); return; } if (line.includes('Software deployed') || line.includes('Software deployed successfully')) { display.subEvent(`${green}✔${reset} Ansible done`); return; } if (line.includes('Infrastructure selected')) { display.subEvent('infrastructure selected'); return; } if (line.includes('Terraform skipped')) { display.subEvent('terraform skipped (existing machine)'); return; } if (line.includes('terraform init') || line.includes('Terraform init')) { display.subEvent('running terraform init'); return; } if (line.includes('terraform apply') || line.includes('Terraform apply')) { display.subEvent('running terraform apply'); return; } const retryMatch = line.match(/RETRYING.*?(\d+) retries left/); if (retryMatch) { display.subEvent(`health check (${retryMatch[1]} retries left)`); return; } // Ansible progress markers emitted via process.stderr in deploy-ansible.ts if (line.startsWith('[ansible:play] ')) { display.subEvent(`${dim}▶${reset} ${line.slice(15)}`); return; } if (line.startsWith('[ansible:task] ')) { display.subEvent(` • ${line.slice(15)}`); return; } if (line.startsWith('[ansible:handler] ')) { display.subEvent(` ↺ ${line.slice(18)}`); return; } if (line.startsWith('[ansible:recap] ')) { const recap = line.slice(16); const failed = recap.match(/failed=(\d+)/)?.[1]; const icon = failed && failed !== '0' ? `${red}✗${reset}` : `${green}✔${reset}`; display.subEvent(`${icon} ${recap}`); return; } // Hook log output: "[moduleId:hookName] message". This fallback fires // when the inner celilo had no display attached, so the lines bypass // the [progress:sub] collapse path. Hide unless --verbose so they // don't pile up as orphan sub-events under unrelated steps. const hookLogMatch = line.match(/^\[[\w-]+:[\w_-]+\] (.*)/); if (hookLogMatch) { if (options.verbose) { display.subEvent(`${dim}hook:${reset} ${hookLogMatch[1]}`); } return; } // Error lines. Require the identifier ending in "Error:" to be either // standalone ("Error:") or PascalCase ("CeliloCommandError:") — NOT a // camelCase variable like "stageError:" that bun prints as source context // for failing tests. const errorMatch = line.match(/\b(?:[A-Z]\w*)?Error: (.*)/); if (errorMatch && !line.includes('bun test') && !line.includes('ipv4: Address already')) { const err = errorMatch[1].slice(0, 70); display.subEvent(`${red}✗${reset} ${err}`); } }