Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 4a681930b1 | |||
| 852b86dded | |||
| 58e7335923 | |||
| 15daea8cc1 | |||
| f31c45db07 | |||
| 217e380ff4 |
@@ -0,0 +1,220 @@
|
||||
name: "retrospect-workflow"
|
||||
description: "Post-execution retrospective: analyze a completed thread, find inefficiencies, and improve the workflow definition."
|
||||
roles:
|
||||
analyst:
|
||||
description: "Scans thread execution for anomalies and produces a findings report"
|
||||
goal: "You are a workflow execution analyst. You review completed thread data to find inefficiencies, wasted effort, and procedure gaps."
|
||||
capabilities:
|
||||
- data-analysis
|
||||
procedure: |
|
||||
You receive a completed thread ID in your task prompt.
|
||||
|
||||
Phase 0 — Validation (must pass before any analysis):
|
||||
1. Run `uwf step list <thread-id>` to get thread metadata including the workflow hash
|
||||
2. Run `uwf workflow show <workflow-hash>` to get the workflow name
|
||||
3. Verify the workflow exists locally: check `.workflows/<name>.yaml` in the current repo
|
||||
- If NOT found: output $status=wrong_project with the workflow name. Do NOT proceed.
|
||||
4. Compare the thread's workflow hash against the current registered version:
|
||||
- Run `uwf workflow show <name>` to get the current hash
|
||||
- If hashes differ: the thread ran on an older version. Note this — you will need to diff versions after analysis.
|
||||
|
||||
Phase 1 — Overview scan:
|
||||
5. From the step list, compute a health signal for each step:
|
||||
- Duration: flag if >2x the median of other steps
|
||||
- Output tokens: flag if >2x the median
|
||||
- Status flow: flag non-happy-path transitions (rejected, fix_code, fix_spec, hook_failed)
|
||||
- Step count: flag if the same role appears more than expected (indicates loops)
|
||||
6. If no anomalies found AND versions match: output $status=clean
|
||||
7. If no anomalies found BUT versions differ:
|
||||
- Diff the two workflow versions to check if any procedure changes are relevant
|
||||
- If the current version already addresses potential concerns: output $status=clean with a note
|
||||
- Otherwise: proceed to Phase 2
|
||||
|
||||
Phase 2 — Targeted deep-dive (only for flagged steps):
|
||||
8. For each flagged step, run `uwf step show <hash>` to get the detail with turns
|
||||
9. Analyze the turn sequence for:
|
||||
- Repeated tool calls with the same or similar input (blind retries)
|
||||
- Tool errors followed by no strategy change (same approach retried)
|
||||
- Unnecessary exploration (reading files or running commands unrelated to the task)
|
||||
- Hallucinated commands or flags (commands that don't exist or wrong syntax)
|
||||
- Excessive turns before reaching the goal
|
||||
10. For each finding, record:
|
||||
- Which role and step hash
|
||||
- What happened (specific turn indices and commands)
|
||||
- Root cause hypothesis (procedure gap, missing pitfall, unclear instruction)
|
||||
- Suggested fix (what to add/change in the procedure)
|
||||
11. If versions differ: compare findings against the version diff.
|
||||
Mark any finding that is already fixed in the current version as "resolved_in_current".
|
||||
Only report findings that are NOT yet addressed.
|
||||
|
||||
Output a structured findings report. Set $status=clean if nothing actionable, $status=findings if unresolved issues exist, or $status=wrong_project if the workflow doesn't belong here.
|
||||
output: "A findings report with per-issue root cause and suggested procedure fixes. Set $status to clean or findings (with report hash)."
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
$status: { const: "clean" }
|
||||
summary: { type: string }
|
||||
required: [$status, summary]
|
||||
- properties:
|
||||
$status: { const: "findings" }
|
||||
report: { type: string }
|
||||
targetWorkflow: { type: string }
|
||||
required: [$status, report, targetWorkflow]
|
||||
- properties:
|
||||
$status: { const: "wrong_project" }
|
||||
workflowName: { type: string }
|
||||
required: [$status, workflowName]
|
||||
proposer:
|
||||
description: "Translates findings into concrete workflow edits"
|
||||
goal: "You are a workflow improvement proposer. You read the analyst's findings and produce specific, minimal edits to the workflow YAML."
|
||||
capabilities:
|
||||
- planning
|
||||
procedure: |
|
||||
1. Read the analyst's findings report from your task prompt
|
||||
2. Locate the target workflow YAML:
|
||||
- Workflow definitions live in the WORKFLOW ENGINE repo (where `uwf` is developed), NOT in the repo that was analyzed.
|
||||
- Find it via: `uwf workflow show <targetWorkflow> --format yaml` to read the current definition
|
||||
- The physical file is `.workflows/<targetWorkflow>.yaml` in the workflow engine repo
|
||||
- Use `git rev-parse --show-toplevel` in the current directory to find the workflow engine repo root
|
||||
3. Read the current workflow YAML to understand existing procedures
|
||||
4. For each finding, draft a minimal edit:
|
||||
- Prefer adding a pitfall note or clarifying instruction over restructuring
|
||||
- If a procedure step is ambiguous, make it explicit
|
||||
- If a tool usage pattern is wrong, add a "Do NOT" or "IMPORTANT" note
|
||||
- Keep edits surgical — don't rewrite procedures that work fine
|
||||
5. Check if existing tests need updating (search for test files referencing the workflow)
|
||||
6. Produce a change plan as CAS text node via `uwf cas put-text "<plan>"`
|
||||
|
||||
The plan should list each edit with:
|
||||
- File path
|
||||
- What to change (old text → new text, or addition)
|
||||
- Why (linked to which finding)
|
||||
- Any test updates needed
|
||||
output: "A change plan stored in CAS. Set $status to ready (with plan hash and repoPath) or no_action (if findings don't warrant changes)."
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
$status: { const: "ready" }
|
||||
plan: { type: string }
|
||||
repoPath: { type: string }
|
||||
required: [$status, plan, repoPath]
|
||||
- properties:
|
||||
$status: { const: "no_action" }
|
||||
reason: { type: string }
|
||||
required: [$status, reason]
|
||||
developer:
|
||||
description: "Applies the proposed workflow edits"
|
||||
goal: "You are a developer agent. You apply workflow YAML edits and update related tests."
|
||||
capabilities:
|
||||
- coding
|
||||
procedure: |
|
||||
IMPORTANT: Always work in a git worktree, NEVER modify the main working directory directly.
|
||||
The workflow definitions live in THIS repo (the workflow engine), not the repo that was analyzed.
|
||||
|
||||
Before starting any work, set up an isolated worktree:
|
||||
1. Use `git rev-parse --show-toplevel` to find the repo root (do NOT use repoPath from proposer — that's the analyzed repo)
|
||||
2. `git fetch origin` to get latest refs
|
||||
3. `git worktree add .worktrees/retrospect/<short-slug> -b retrospect/<short-slug> origin/main`
|
||||
4. `cd .worktrees/retrospect/<short-slug> && bun install`
|
||||
5. ALL subsequent work must happen inside the worktree directory.
|
||||
|
||||
Then apply changes:
|
||||
6. Read the change plan from CAS: `uwf cas get <plan hash>`
|
||||
7. Apply each edit from the plan to the workflow YAML
|
||||
8. Update or add tests as specified in the plan
|
||||
9. Run `bun run build` and `bun test` to verify
|
||||
10. Run `bun run check` for lint
|
||||
11. Commit with message: `improve: <workflow-name> — <brief summary>`
|
||||
output: "List all files changed and provide a summary. Set $status to done (with branch/worktree), or failed (with reason)."
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
$status: { const: "done" }
|
||||
branch: { type: string }
|
||||
worktree: { type: string }
|
||||
required: [$status, branch, worktree]
|
||||
- properties:
|
||||
$status: { const: "failed" }
|
||||
reason: { type: string }
|
||||
required: [$status, reason]
|
||||
reviewer:
|
||||
description: "Reviews the workflow edits for correctness"
|
||||
goal: "You are a reviewer. You verify that workflow edits are minimal, correct, and actually address the findings."
|
||||
capabilities:
|
||||
- code-review
|
||||
procedure: |
|
||||
The worktree path is provided in your task prompt. cd into it first.
|
||||
|
||||
Review criteria:
|
||||
1. Each edit must trace back to a specific finding — no drive-by changes
|
||||
2. Edits should be minimal — don't rewrite working procedures
|
||||
3. New pitfall notes or instructions must be clear and actionable
|
||||
4. Tests must be updated if assertions changed
|
||||
5. `bun run build` and `bun test` must pass
|
||||
6. `bunx biome check` must pass
|
||||
|
||||
IMPORTANT: `tea pr create` must run from the MAIN repo directory (not a worktree), because tea cannot detect the repo from worktree `.git` files.
|
||||
output: "Explain your decision. Set $status to approved (with branch/worktree) or rejected (with comments)."
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
$status: { const: "approved" }
|
||||
branch: { type: string }
|
||||
worktree: { type: string }
|
||||
required: [$status, branch, worktree]
|
||||
- properties:
|
||||
$status: { const: "rejected" }
|
||||
comments: { type: string }
|
||||
worktree: { type: string }
|
||||
required: [$status, comments, worktree]
|
||||
committer:
|
||||
description: "Commits and creates PR"
|
||||
goal: "You are a committer agent. You create a clean commit and push a PR."
|
||||
capabilities: []
|
||||
procedure: |
|
||||
The worktree path, branch name, and repo info are provided in your task prompt.
|
||||
cd into the worktree first.
|
||||
|
||||
Note: You inherit the developer's worktree and branch. Do NOT create a new branch.
|
||||
1. Stage all changes: `git add -A`
|
||||
2. Commit with a descriptive message: `git commit -m "improve: <workflow> — <summary>"`
|
||||
3. Push the branch: `git push -u origin <branch-name>`
|
||||
- If push hook fails: capture the error log in your output, mark hook_failed
|
||||
4. On push success: create a PR via `tea pr create --title "..." --description "..."`
|
||||
- IMPORTANT: `tea pr create` must run from the MAIN repo directory (not a worktree), because tea cannot detect the repo from worktree `.git` files. cd to the repo root first.
|
||||
- Do NOT pass `--repo` — let tea auto-detect from the main repo's git remote.
|
||||
- PR description must include: What / Why / Findings / Changes sections
|
||||
- On tea failure: capture stderr/stdout, include PR details for manual creation, mark hook_failed
|
||||
5. After PR creation, clean up the worktree:
|
||||
- cd to the repo root (parent of .worktrees)
|
||||
- `git worktree remove <worktree-path>`
|
||||
output: "Include PR URL on success or error log on failure. Set $status to committed (with prUrl) or hook_failed (with error)."
|
||||
frontmatter:
|
||||
oneOf:
|
||||
- properties:
|
||||
$status: { const: "committed" }
|
||||
prUrl: { type: string }
|
||||
required: [$status, prUrl]
|
||||
- properties:
|
||||
$status: { const: "hook_failed" }
|
||||
error: { type: string }
|
||||
required: [$status, error]
|
||||
graph:
|
||||
$START:
|
||||
_: { role: "analyst", prompt: "Analyze completed thread {{{threadId}}} for execution anomalies." }
|
||||
analyst:
|
||||
clean: { role: "$END", prompt: "No issues found. Thread executed cleanly." }
|
||||
findings: { role: "proposer", prompt: "Findings report: {{{report}}}. Target workflow: {{{targetWorkflow}}}. Propose minimal edits." }
|
||||
wrong_project: { role: "$END", prompt: "Thread uses workflow '{{{workflowName}}}' which does not exist in this project. Run retrospect from the correct repo." }
|
||||
proposer:
|
||||
no_action: { role: "$END", prompt: "No actionable changes needed: {{{reason}}}." }
|
||||
ready: { role: "developer", prompt: "Apply the change plan (CAS hash: {{{plan}}}) to the workflow definitions in this repo." }
|
||||
developer:
|
||||
done: { role: "reviewer", prompt: "Review workflow edits on branch {{{branch}}} at {{{worktree}}}." }
|
||||
failed: { role: "$END", prompt: "Developer failed: {{{reason}}}. Ending workflow." }
|
||||
reviewer:
|
||||
rejected: { role: "developer", prompt: "Reviewer rejected: {{{comments}}}. Fix the issues in {{{worktree}}}." }
|
||||
approved: { role: "committer", prompt: "Approved. Commit and push branch {{{branch}}} from {{{worktree}}}." }
|
||||
committer:
|
||||
hook_failed: { role: "developer", prompt: "Push hook failed: {{{error}}}. Fix and re-submit." }
|
||||
committed: { role: "$END", prompt: "PR created: {{{prUrl}}}. Workflow improved." }
|
||||
@@ -242,12 +242,11 @@ roles:
|
||||
capabilities: []
|
||||
procedure: "The worktree path, branch name, and repo remote (owner/repo) are provided in your task prompt.\ncd into the worktree first.\n\nNote: You inherit the developer's worktree and branch. Do NOT\
|
||||
\ create a new branch.\n1. Stage all changes: `git add -A`\n2. Commit with a descriptive message referencing the issue: `git commit -m \"type: description\\n\\nFixes #N\"`\n3. Push the branch: `git\
|
||||
\ push -u origin <branch-name>`\n - If push hook fails: capture the error log in your output, mark hook_failed\n4. On push success: create a PR via `tea pr create --repo <repoRemote> --title \"\
|
||||
...\" --description \"...\"`\n - The repo remote (owner/repo format, e.g. \"uncaged/workflow\") is given in your task prompt — use it directly, do NOT try to parse it from git remote URL.\n -\
|
||||
\ PR description must include: What / Why / Changes / Ref sections, with `Fixes #N` in Ref\n - If `tea pr create` fails, try the Gitea API as fallback:\n ```bash\n GITEA_TOKEN=$(cfg get\
|
||||
\ GITEA_TOKEN)\n curl -s -X POST -H \"Authorization: token $GITEA_TOKEN\" -H \"Content-Type: application/json\" \\\n \"https://git.shazhou.work/api/v1/repos/<owner>/<repo>/pulls\" \\\n \
|
||||
\ -d '{\"title\":\"...\",\"body\":\"...\",\"head\":\"<branch>\",\"base\":\"main\"}'\n ```\n - On total failure: capture stderr/stdout, include PR details for manual creation, mark hook_failed\n\
|
||||
5. After PR creation, clean up the worktree:\n - cd to the repo root (parent of .worktrees)\n - `git worktree remove <worktree-path>`"
|
||||
\ push -u origin <branch-name>`\n4. **Verify push succeeded** — run `git ls-remote origin <branch-name>` and confirm it prints a commit hash.\n - If no output or push failed: capture the error, mark hook_failed\n\
|
||||
5. Create a PR using the Gitea API (do NOT use `tea pr create` — it fails in worktrees):\n ```bash\n GITEA_TOKEN=$(cfg get GITEA_TOKEN)\n curl -s -X POST -H \"Authorization: token $GITEA_TOKEN\" -H \"Content-Type: application/json\" \\\n\
|
||||
\ \"https://git.shazhou.work/api/v1/repos/<owner>/<repo>/pulls\" \\\n -d '{\"title\":\"...\",\"body\":\"...\",\"head\":\"<branch>\",\"base\":\"main\"}'\n ```\n - The repo remote (owner/repo format, e.g. \"uncaged/workflow\") is given in your task prompt — use it directly.\n\
|
||||
\ - PR body must include: What / Why / Changes / Ref sections, with `Fixes #N` in Ref\n6. **Verify PR was created** — parse the curl response JSON: it must contain a `\"number\"` field. Print the PR URL.\n\
|
||||
\ - If curl returns an error or no number field: capture the response, mark hook_failed\n7. After PR creation, clean up the worktree:\n - cd to the repo root (parent of .worktrees)\n - `git worktree remove <worktree-path>`"
|
||||
output: Include PR URL on success or error log on failure. Set $status to committed (with prUrl) or hook_failed (with error).
|
||||
frontmatter:
|
||||
oneOf:
|
||||
|
||||
@@ -57,6 +57,26 @@ h1 {
|
||||
color: #666;
|
||||
font-size: 0.85rem;
|
||||
}
|
||||
.search-input {
|
||||
width: 100%;
|
||||
background: #2a2a4a;
|
||||
color: #e0e0e0;
|
||||
border: 1px solid #3a3a5a;
|
||||
padding: 10px 14px;
|
||||
border-radius: 6px;
|
||||
font-size: 0.9rem;
|
||||
margin-bottom: 12px;
|
||||
}
|
||||
.search-input:focus {
|
||||
outline: none;
|
||||
border-color: #7c8aff;
|
||||
}
|
||||
mark {
|
||||
background: #fbbf24;
|
||||
color: #1a1a2e;
|
||||
padding: 1px 2px;
|
||||
border-radius: 2px;
|
||||
}
|
||||
.filters {
|
||||
display: flex;
|
||||
gap: 8px;
|
||||
|
||||
@@ -26,3 +26,208 @@ describe("HTML Page Title", () => {
|
||||
expect(content).not.toContain("<title>UWF Dashboard</title>");
|
||||
});
|
||||
});
|
||||
|
||||
describe("Search Filtering with Highlight", () => {
|
||||
it("should contain a search input element with class 'search-input'", () => {
|
||||
const content = readFileSync(join(__dirname, "App.tsx"), "utf-8");
|
||||
expect(content).toMatch(/<input[^>]*type=["'](text|search)["'][^>]*>/);
|
||||
expect(content).toMatch(/className=["'][^"']*search-input[^"']*["']/);
|
||||
expect(content).toMatch(/placeholder=["'][^"']*[Ss]earch[^"']*["']/);
|
||||
});
|
||||
|
||||
it("should declare a search query state variable", () => {
|
||||
const content = readFileSync(join(__dirname, "App.tsx"), "utf-8");
|
||||
expect(content).toMatch(
|
||||
/const\s+\[\s*\w+\s*,\s*set\w+\s*\]\s*=\s*useState[<\w>]*\(\s*["']["']?\s*\)/,
|
||||
);
|
||||
// Verify it's a string state for search
|
||||
expect(content).toMatch(/useState<string>\(["''"]|useState\(["''"]/);
|
||||
});
|
||||
|
||||
it("should use useMemo to compute filtered records", () => {
|
||||
const content = readFileSync(join(__dirname, "App.tsx"), "utf-8");
|
||||
expect(content).toContain("useMemo");
|
||||
// Check for dependency array containing records and search variable
|
||||
expect(content).toMatch(/useMemo\([\s\S]+?,\s*\[\s*records\s*,\s*\w+\s*\]\s*\)/);
|
||||
});
|
||||
|
||||
it("should implement case-insensitive filtering on command, device, and id fields", () => {
|
||||
const content = readFileSync(join(__dirname, "App.tsx"), "utf-8");
|
||||
|
||||
// Check for toLowerCase usage in filtering context
|
||||
const lowerCaseCount = (content.match(/\.toLowerCase\(\)/g) || []).length;
|
||||
expect(lowerCaseCount).toBeGreaterThanOrEqual(2); // At least for query and one field
|
||||
|
||||
// Check for includes method (used for substring matching)
|
||||
expect(content).toMatch(/\.includes\(/);
|
||||
|
||||
// Verify filtering checks command, device, and id
|
||||
expect(content).toMatch(/r\.command/);
|
||||
expect(content).toMatch(/r\.device/);
|
||||
expect(content).toMatch(/r\.id/);
|
||||
});
|
||||
|
||||
it("should define a Highlight component that wraps matches with <mark> tags", () => {
|
||||
const content = readFileSync(join(__dirname, "App.tsx"), "utf-8");
|
||||
|
||||
// Check for Highlight component definition
|
||||
expect(content).toMatch(/function\s+Highlight|const\s+Highlight\s*[:=]/);
|
||||
|
||||
// Check for mark tag usage
|
||||
expect(content).toContain("mark");
|
||||
|
||||
// Check for props handling (text and query or similar)
|
||||
expect(content).toMatch(
|
||||
/\{\s*text\s*,\s*query\s*\}|\{\s*text\s*:\s*\w+\s*,\s*query\s*:\s*\w+\s*\}/,
|
||||
);
|
||||
});
|
||||
|
||||
it("should apply Highlight component to command column in record rows", () => {
|
||||
const content = readFileSync(join(__dirname, "App.tsx"), "utf-8");
|
||||
|
||||
// Check for Highlight usage with command field
|
||||
expect(content).toMatch(/<Highlight[^>]*text=\{r\.command\}/);
|
||||
|
||||
// Verify it's within the command span context
|
||||
const commandSpanMatch = content.match(
|
||||
/<span[^>]*className=["']command["'][^>]*>[\s\S]*?<\/span>/,
|
||||
);
|
||||
expect(commandSpanMatch).toBeTruthy();
|
||||
if (commandSpanMatch) {
|
||||
expect(commandSpanMatch[0]).toContain("Highlight");
|
||||
}
|
||||
});
|
||||
|
||||
it("should apply Highlight component to device badge in record rows", () => {
|
||||
const content = readFileSync(join(__dirname, "App.tsx"), "utf-8");
|
||||
|
||||
// Check for Highlight usage with device field
|
||||
expect(content).toMatch(/<Highlight[^>]*text=\{r\.device\}/);
|
||||
|
||||
// Verify it's within the device-badge span context
|
||||
const deviceBadgeMatch = content.match(
|
||||
/<span[^>]*className=["']device-badge["'][^>]*>[\s\S]*?<\/span>/,
|
||||
);
|
||||
expect(deviceBadgeMatch).toBeTruthy();
|
||||
if (deviceBadgeMatch) {
|
||||
expect(deviceBadgeMatch[0]).toContain("Highlight");
|
||||
}
|
||||
});
|
||||
|
||||
it("should show 'No matches' when search has no results vs 'No records yet' when empty", () => {
|
||||
const content = readFileSync(join(__dirname, "App.tsx"), "utf-8");
|
||||
|
||||
// Check for "No records yet" message
|
||||
expect(content).toContain("No records yet");
|
||||
|
||||
// Check for "No matches" or similar search-specific empty message
|
||||
expect(content).toMatch(/No matches|No search results|No records found/i);
|
||||
});
|
||||
|
||||
it("should define .search-input styles consistent with dark theme", () => {
|
||||
const cssContent = readFileSync(join(__dirname, "App.css"), "utf-8");
|
||||
|
||||
// Check for .search-input selector
|
||||
expect(cssContent).toMatch(/\.search-input\s*\{/);
|
||||
|
||||
// Extract the search-input block
|
||||
const searchInputBlock = cssContent.match(/\.search-input\s*\{[^}]+\}/);
|
||||
expect(searchInputBlock).toBeTruthy();
|
||||
|
||||
if (searchInputBlock) {
|
||||
const block = searchInputBlock[0];
|
||||
// Dark background
|
||||
expect(block).toMatch(/background:\s*#[0-9a-fA-F]{6}/);
|
||||
// Light text
|
||||
expect(block).toMatch(/color:\s*#[0-9a-fA-F]{6}/);
|
||||
// Border
|
||||
expect(block).toMatch(/border:/);
|
||||
// Padding
|
||||
expect(block).toMatch(/padding:/);
|
||||
// Border radius
|
||||
expect(block).toMatch(/border-radius:/);
|
||||
}
|
||||
});
|
||||
|
||||
it("should define mark element styles with yellow/orange background for dark theme visibility", () => {
|
||||
const cssContent = readFileSync(join(__dirname, "App.css"), "utf-8");
|
||||
|
||||
// Check for mark selector
|
||||
expect(cssContent).toMatch(/\bmark\s*\{|\.mark\s*\{/);
|
||||
|
||||
// Extract the mark block
|
||||
const markBlock = cssContent.match(/\bmark\s*\{[^}]+\}/);
|
||||
expect(markBlock).toBeTruthy();
|
||||
|
||||
if (markBlock) {
|
||||
const block = markBlock[0];
|
||||
// Yellow/orange background (hex codes starting with #f, #fb, #fc, #d, #e, etc.)
|
||||
expect(block).toMatch(/background[^:]*:\s*#[def][0-9a-fA-F]{5}/);
|
||||
// Should have color defined for text
|
||||
expect(block).toMatch(/color:/);
|
||||
}
|
||||
});
|
||||
|
||||
it("should place search input above device filter buttons", () => {
|
||||
const content = readFileSync(join(__dirname, "App.tsx"), "utf-8");
|
||||
|
||||
// Extract the return JSX section
|
||||
const returnMatch = content.match(/return\s*\(([\s\S]+)\);?\s*\}/);
|
||||
expect(returnMatch).toBeTruthy();
|
||||
|
||||
if (returnMatch) {
|
||||
const jsx = returnMatch[1];
|
||||
|
||||
// Find position of search input
|
||||
const searchInputPos = jsx.search(/<input[^>]*search-input/);
|
||||
expect(searchInputPos).toBeGreaterThan(-1);
|
||||
|
||||
// Find position of filters div
|
||||
const filtersPos = jsx.search(/<div[^>]*className=["']filters["']/);
|
||||
expect(filtersPos).toBeGreaterThan(-1);
|
||||
|
||||
// Search input should come before filters
|
||||
expect(searchInputPos).toBeLessThan(filtersPos);
|
||||
}
|
||||
});
|
||||
|
||||
it("should render filtered records instead of raw records in the list", () => {
|
||||
const content = readFileSync(join(__dirname, "App.tsx"), "utf-8");
|
||||
|
||||
// Check for a filtered variable being mapped
|
||||
const useMemoMatch = content.match(/const\s+(\w+)\s*=\s*useMemo/);
|
||||
expect(useMemoMatch).toBeTruthy();
|
||||
|
||||
if (useMemoMatch) {
|
||||
const filteredVar = useMemoMatch[1];
|
||||
|
||||
// Check that this variable is used in .map()
|
||||
const mapPattern = new RegExp(`\\{${filteredVar}\\.map\\(`);
|
||||
expect(content).toMatch(mapPattern);
|
||||
}
|
||||
|
||||
// Additionally ensure raw records.map is NOT used in the render section
|
||||
// (after the useMemo definition)
|
||||
const useMemoIndex = content.indexOf("useMemo");
|
||||
const recordsMapMatch = content.slice(useMemoIndex).match(/\{records\.map\(/);
|
||||
expect(recordsMapMatch).toBeNull();
|
||||
});
|
||||
|
||||
it("should handle empty search query gracefully", () => {
|
||||
const content = readFileSync(join(__dirname, "App.tsx"), "utf-8");
|
||||
|
||||
// Check that filtering logic handles empty query
|
||||
const useMemoBlock = content.match(/useMemo\([^}]+\}/);
|
||||
expect(useMemoBlock).toBeTruthy();
|
||||
|
||||
// Should either have early return for empty query OR filter logic that handles it
|
||||
expect(content).toMatch(/if\s*\(\s*!\w+|if\s*\(\s*\w+\.trim\(\)|\.includes\(/);
|
||||
});
|
||||
|
||||
it("should import useMemo from React", () => {
|
||||
const content = readFileSync(join(__dirname, "App.tsx"), "utf-8");
|
||||
|
||||
// Check for useMemo in React import (may have React, before the destructure)
|
||||
expect(content).toMatch(/import\s+.*\{[^}]*useMemo[^}]*\}\s+from\s+["']react["']/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, { useEffect, useState, useRef } from "react";
|
||||
import React, { useEffect, useState, useRef, useMemo } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
interface Record {
|
||||
@@ -29,6 +29,25 @@ function fmtDuration(ms: number) {
|
||||
return `${(ms / 1000).toFixed(1)}s`;
|
||||
}
|
||||
|
||||
function Highlight({ text, query }: { text: string; query: string }) {
|
||||
if (!query.trim()) return <>{text}</>;
|
||||
|
||||
const regex = new RegExp(`(${query.replace(/[.*+?^${}()|[\]\\]/g, "\\$&")})`, "gi");
|
||||
const parts = text.split(regex);
|
||||
|
||||
return (
|
||||
<>
|
||||
{parts.map((part, i) =>
|
||||
regex.test(part) ? (
|
||||
<mark key={`${i}-${part}`}>{part}</mark>
|
||||
) : (
|
||||
<span key={`${i}-${part}`}>{part}</span>
|
||||
),
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [records, setRecords] = useState<Record[]>([]);
|
||||
const [workers, setWorkers] = useState<Worker[]>([]);
|
||||
@@ -36,9 +55,23 @@ export default function App() {
|
||||
[],
|
||||
);
|
||||
const [filter, setFilter] = useState("");
|
||||
const [searchQuery, setSearchQuery] = useState<string>("");
|
||||
const nav = useNavigate();
|
||||
const wsRef = useRef<WebSocket | null>(null);
|
||||
|
||||
const filteredRecords = useMemo(() => {
|
||||
if (!searchQuery.trim()) return records;
|
||||
|
||||
const lowerQuery = searchQuery.toLowerCase();
|
||||
return records.filter((r) => {
|
||||
return (
|
||||
r.command.toLowerCase().includes(lowerQuery) ||
|
||||
r.device.toLowerCase().includes(lowerQuery) ||
|
||||
r.id.toLowerCase().includes(lowerQuery)
|
||||
);
|
||||
});
|
||||
}, [records, searchQuery]);
|
||||
|
||||
useEffect(() => {
|
||||
fetch(`/api/records${filter ? `?device=${filter}` : ""}`)
|
||||
.then((r) => r.json())
|
||||
@@ -94,6 +127,13 @@ export default function App() {
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
<input
|
||||
type="text"
|
||||
className="search-input"
|
||||
placeholder="Search records by command, device, or ID..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
/>
|
||||
<div className="filters">
|
||||
<button type="button" className={!filter ? "active" : ""} onClick={() => setFilter("")}>
|
||||
All
|
||||
@@ -111,15 +151,22 @@ export default function App() {
|
||||
</div>
|
||||
<div className="records">
|
||||
{records.length === 0 && <div className="empty">No records yet</div>}
|
||||
{records.map((r) => (
|
||||
{records.length > 0 && filteredRecords.length === 0 && searchQuery && (
|
||||
<div className="empty">No matches found</div>
|
||||
)}
|
||||
{filteredRecords.map((r) => (
|
||||
<button
|
||||
type="button"
|
||||
key={r.id}
|
||||
className="record-row"
|
||||
onClick={() => nav(`/record/${r.id}`)}
|
||||
>
|
||||
<span className="device-badge">{r.device}</span>
|
||||
<span className="command">{r.command}</span>
|
||||
<span className="device-badge">
|
||||
<Highlight text={r.device} query={searchQuery} />
|
||||
</span>
|
||||
<span className="command">
|
||||
<Highlight text={r.command} query={searchQuery} />
|
||||
</span>
|
||||
<span className={`exit-code ${r.exitCode === 0 ? "success" : "error"}`}>
|
||||
{r.exitCode === 0 ? "✓" : `✗ ${r.exitCode}`}
|
||||
</span>
|
||||
|
||||
Reference in New Issue
Block a user