Files
jorgecuadros-platform/apps/api/src/ops/job-progress.spec.ts
T
rmancinasandClaude Opus 5 66d0d071b0
Build and Push Images / Build jorgecuadros-web (push) Successful in 1m42s
Build and Push Images / Build jorgecuadros-api (push) Successful in 2m18s
feat(ops): show step progress for reimport and sync jobs
A REIMPORT takes ~110 seconds and, until now, showed only a scrolling log —
there was no way to tell "halfway" from "wedged", which mattered the day one
actually did wedge.

run_all.py emits "[paso i/N] name" before each step and the API derives
progress from the job log. Emitting the marker from the Python rather than
having the UI count STEPS itself means the step count is stated in exactly
one place; adding a step cannot desync the display. Progress is derived, not
stored, for the same reason: the log is already the record of what happened,
and a separate counter could contradict it, which is precisely the confusion
a progress display exists to remove.

While RUNNING, step i is IN PROGRESS rather than finished, so only i-1 count
as done. Counting i would show 100% while the final step was still working —
and the final step (blob_extract) is the slowest, so the bar would sit at
"100%" for the longest stretch of the job.

BACKUP and RESTORE are a single mysqldump with no steps and deliberately
render no bar; a fabricated percentage would be worse than none. The safety
backup that precedes a REIMPORT is likewise named explicitly instead of
showing 0%, which reads as stuck.

Pinned by job-progress.spec.ts, including the literal line run_all.py emits,
so a change to the Python format fails a test rather than silently blanking
the panel.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-03 17:53:10 -07:00

77 lines
3.3 KiB
TypeScript

import { jobProgress } from "./ops.service";
/** Shape run_all.py emits, with the shell trace lines it interleaves. */
const line = (i: number, n: number, name: string) =>
`[paso ${i}/${n}] ${name}\n+ /repo/migration/.venv/bin/python /repo/migration/${name} --env prod\n[${name}] target env: prod\n validation: OK`;
describe("jobProgress", () => {
it("returns null before any step marker appears", () => {
// The safety backup runs before run_all.py, so this is the real state for
// the first stretch of every REIMPORT.
expect(jobProgress("== Respaldo de seguridad previo ==\ntablas capturadas: 39", "RUNNING")).toBeNull();
});
it("returns null for jobs that have no steps at all", () => {
// BACKUP/RESTORE are a single mysqldump; a fabricated percentage would be
// worse than none.
expect(jobProgress("mysqldump ... done", "SUCCESS")).toBeNull();
});
it("tracks the most recent marker, not the first", () => {
const log = [line(1, 9, "transform_customers.py"), line(2, 9, "transform_properties.py")].join("\n");
const p = jobProgress(log, "RUNNING");
expect(p).toMatchObject({ step: 2, total: 9, name: "transform_properties.py" });
});
/**
* The point of the whole feature. While RUNNING, step i is IN PROGRESS, so
* only i-1 are done. Counting i as complete would show 100% while the final
* and slowest step (blob_extract) is still working.
*/
it("does not claim a running step is finished", () => {
expect(jobProgress(line(1, 9, "transform_customers.py"), "RUNNING")?.percent).toBe(0);
expect(jobProgress(line(9, 9, "blob_extract.py"), "RUNNING")?.percent).toBe(88);
});
it("reaches 100 only once the job is no longer running", () => {
expect(jobProgress(line(9, 9, "blob_extract.py"), "SUCCESS")?.percent).toBe(100);
});
/** A job that died mid-way must report where it died, not 100%. */
it("reports the failed step rather than completion", () => {
const p = jobProgress(line(5, 9, "transform_transactions.py"), "FAILED");
expect(p).toMatchObject({ step: 5, total: 9 });
expect(p!.percent).toBe(55);
});
it("handles the 8-step SYNC list as well as the 9-step REIMPORT one", () => {
expect(jobProgress(line(8, 8, "transform_bank.py"), "SUCCESS")?.percent).toBe(100);
expect(jobProgress(line(4, 8, "transform_policies.py"), "RUNNING")?.percent).toBe(37);
});
/**
* Captured verbatim from `run_all.run(..., step=8, total=9)`. This is the
* contract between the Python and this parser; if run_all.py's format
* changes, this fails rather than the panel silently showing no progress.
*/
it("parses the exact line run_all.py emits", () => {
const real =
"[paso 8/9] transform_bank.py\n+ /repo/migration/.venv/bin/python /repo/migration/transform_bank.py --env prod";
expect(jobProgress(real, "RUNNING")).toMatchObject({
step: 8,
total: 9,
name: "transform_bank.py",
percent: 77,
});
});
it("ignores a malformed marker instead of reporting NaN", () => {
expect(jobProgress("[paso 3/0] x.py", "RUNNING")).toBeNull();
});
/** The marker must be at line start so log text quoting it cannot spoof it. */
it("does not match a marker embedded mid-line", () => {
expect(jobProgress("some output mentioning [paso 4/9] fake.py", "RUNNING")).toBeNull();
});
});