diff --git a/open-sse/services/compression/engines/rtk/commandDetector.ts b/open-sse/services/compression/engines/rtk/commandDetector.ts index 31806c65b7..07207c2c3b 100644 --- a/open-sse/services/compression/engines/rtk/commandDetector.ts +++ b/open-sse/services/compression/engines/rtk/commandDetector.ts @@ -55,6 +55,9 @@ const COMMAND_PREFIXES = [ "golangci-lint", "bundle", "rubocop", + "kubectl", + "composer", + "gh", "docker", "aws", "gcloud", diff --git a/open-sse/services/compression/engines/rtk/filters/composer.json b/open-sse/services/compression/engines/rtk/filters/composer.json new file mode 100644 index 0000000000..1391699634 --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/composer.json @@ -0,0 +1,81 @@ +{ + "id": "composer", + "label": "Composer (PHP)", + "description": "Keep Composer install/update results, warnings, and dependency errors.", + "category": "package", + "priority": 65, + "match": { + "outputTypes": ["composer"], + "commands": [ + "^composer\\s+(?:install|update|require|remove|dump-autoload|outdated)\\b", + "^php\\s+composer(?:\\.phar)?\\s+(?:install|update|require)\\b" + ], + "patterns": [ + "Package operations: \\d+ install", + "Generating (?:optimized )?autoload files", + "Your requirements could not be resolved", + "Problem \\d+", + "vulnerabilities found" + ] + }, + "rules": { + "stripAnsi": true, + "includePatterns": [ + "Package operations: \\d+", + "Generating (?:optimized )?autoload files", + "Your requirements could not be resolved", + "Problem \\d+", + "vulnerabilities found", + "WARNING", + "ERROR", + "Could not (?:find|resolve|delete)", + "conflicts with", + "requires .+ but", + "- (?:Installing|Updating|Removing) \\S+", + "PHP Fatal error", + "Class .+ not found" + ], + "dropPatterns": [ + "^\\s*$", + "^Loading composer repositories", + "^Updating dependencies", + "^Writing lock file", + "^Installing dependencies from lock file", + "^Downloading \\(\\d+%\\)", + "^\\s*-\\s+Downloading" + ], + "collapsePatterns": ["^\\s*-\\s+Installing"], + "deduplicate": true, + "maxLines": 100, + "headLines": 20, + "tailLines": 50 + }, + "preserve": { + "errorPatterns": [ + "Your requirements could not be resolved", + "Problem \\d+", + "ERROR", + "PHP Fatal error", + "Class .+ not found" + ], + "summaryPatterns": [ + "Package operations: \\d+", + "Generating (?:optimized )?autoload files", + "vulnerabilities found" + ] + }, + "tests": [ + { + "name": "successful install", + "command": "composer install", + "input": "Loading composer repositories with package information\nUpdating dependencies\nLock file operations: 5 installs, 0 updates, 0 removals\n - Locking laravel/framework (v11.0.0)\nWriting lock file\nInstalling dependencies from lock file (including require-dev)\nPackage operations: 5 installs, 0 updates, 0 removals\n - Downloading laravel/framework (v11.0.0)\n - Installing laravel/framework (v11.0.0)\nGenerating optimized autoload files\n", + "expected": "Package operations: 5 installs, 0 updates, 0 removals\n - Installing laravel/framework (v11.0.0)\nGenerating optimized autoload files" + }, + { + "name": "dependency conflict", + "command": "composer require", + "input": "Your requirements could not be resolved to an installable set of packages.\n\n Problem 1\n - laravel/framework[v11.0.0] requires php ^8.2 but your php version (8.1.0) does not satisfy that requirement.\n", + "expected": "Your requirements could not be resolved to an installable set of packages.\n Problem 1\n - laravel/framework[v11.0.0] requires php ^8.2 but your php version (8.1.0) does not satisfy that requirement." + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/docker-build.json b/open-sse/services/compression/engines/rtk/filters/docker-build.json new file mode 100644 index 0000000000..efdf5452cf --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/docker-build.json @@ -0,0 +1,78 @@ +{ + "id": "docker-build", + "label": "Docker Build", + "description": "Keep Docker build errors and final summary while stripping layer-by-layer progress.", + "category": "docker", + "priority": 82, + "match": { + "outputTypes": ["docker-build"], + "commands": [ + "^docker\\s+(?:build|buildx\\s+build)\\b", + "^(?:docker\\s+compose|docker-compose)\\s+build\\b" + ], + "patterns": [ + "Successfully built \\w+", + "Successfully tagged \\S+", + "writing image sha256:", + "ERROR \\[", + "exit code: \\d+" + ] + }, + "rules": { + "stripAnsi": true, + "includePatterns": [ + "ERROR", + "ERR!", + "exit code: \\d+", + "failed to solve", + "failed to compute cache key", + "Cannot connect to the Docker daemon", + "no such file or directory", + "Successfully built \\w+", + "Successfully tagged \\S+", + "writing image sha256:", + "naming to docker\\.io/\\S+", + "DONE", + "^Step \\d+/\\d+ :", + "Sending build context", + "WARN\\s*\\[" + ], + "dropPatterns": [ + "^#\\d+\\s+sha256:", + "^\\s*$", + "^\\s+\\d+\\.\\d+s$", + "^\\s*-+>\\s*Running in", + "^\\s*Removing intermediate container", + "^\\s*-+>\\s*[a-f0-9]{12}$" + ], + "collapsePatterns": ["^#\\d+\\s+CACHED", "^\\s*Pulling fs layer"], + "deduplicate": false, + "maxLines": 100, + "headLines": 15, + "tailLines": 50 + }, + "preserve": { + "errorPatterns": ["ERROR", "exit code: \\d+", "failed to solve", "Cannot connect"], + "summaryPatterns": ["Successfully built", "Successfully tagged", "writing image", "DONE"] + }, + "tests": [ + { + "name": "successful build", + "command": "docker build", + "input": "Step 1/3 : FROM node:20\nStep 2/3 : COPY . /app\nStep 3/3 : RUN npm install\n ---> Running in abc123\n ---> def456\nRemoving intermediate container abc123\nSuccessfully built def456\nSuccessfully tagged myapp:latest\n", + "expected": "Step 1/3 : FROM node:20\nStep 2/3 : COPY . /app\nStep 3/3 : RUN npm install\nSuccessfully built def456\nSuccessfully tagged myapp:latest" + }, + { + "name": "build failure", + "command": "docker build", + "input": "Step 1/2 : FROM node:20\nStep 2/2 : RUN npm install\nERROR: failed to solve: process \"/bin/sh -c npm install\" did not complete successfully: exit code: 1\n", + "expected": "Step 1/2 : FROM node:20\nStep 2/2 : RUN npm install\nERROR: failed to solve: process \"/bin/sh -c npm install\" did not complete successfully: exit code: 1" + }, + { + "name": "preserves buildkit-prefixed RUN step errors", + "command": "docker build", + "input": "#8 [build 3/4] RUN npm run build\n#8 0.456 src/a.ts:1:1 - ERROR TS2322: Type 'string' is not assignable\n#8 0.789 npm ERR! exit code 1\n#8 ERROR: process \"/bin/sh -c npm run build\" did not complete successfully: exit code: 1\n", + "expected": "#8 0.456 src/a.ts:1:1 - ERROR TS2322: Type 'string' is not assignable\n#8 0.789 npm ERR! exit code 1\n#8 ERROR: process \"/bin/sh -c npm run build\" did not complete successfully: exit code: 1" + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/gh.json b/open-sse/services/compression/engines/rtk/filters/gh.json new file mode 100644 index 0000000000..42fe6e9865 --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/gh.json @@ -0,0 +1,98 @@ +{ + "id": "gh", + "label": "GitHub CLI", + "description": "Keep GitHub CLI command results, errors, and PR/issue summaries while stripping pagination noise. Skips `gh api` and `--json` invocations (structured output that would be corrupted by line filtering).", + "category": "cloud", + "priority": 70, + "match": { + "outputTypes": ["gh"], + "commands": [ + "^gh\\s+(?:pr|issue|repo|run|workflow|release|auth|browse|gist|secret|label|search|status)(?!.*--json)\\b" + ], + "patterns": [ + "^https?://github\\.com/", + "Created pull request", + "Created issue", + "✓\\s+Logged in", + "X\\s+\\S+", + "GraphQL error", + "HTTP \\d{3}:" + ] + }, + "rules": { + "stripAnsi": true, + "includePatterns": [ + "^https?://github\\.com/", + "Creat(?:ed|ing) pull request", + "Creat(?:ed|ing) issue", + "Merged pull request", + "Closed (?:pull request|issue)", + "Reopened", + "✓\\s+", + "X\\s+\\S+", + "FAIL", + "GraphQL error", + "HTTP \\d{3}:", + "error:", + "not found", + "must be authenticated", + "rate limit", + "completed\\s+\\w+", + "queued\\s+\\w+", + "in_progress\\s+\\w+", + "#\\d+\\s+\\S" + ], + "dropPatterns": [ + "^\\s*$", + "^Showing \\d+ of \\d+", + "^For more information on output formatting" + ], + "collapsePatterns": [], + "deduplicate": false, + "maxLines": 80, + "headLines": 20, + "tailLines": 40 + }, + "preserve": { + "errorPatterns": [ + "GraphQL error", + "HTTP \\d{3}:", + "error:", + "not found", + "must be authenticated", + "rate limit" + ], + "summaryPatterns": [ + "Creat(?:ed|ing) pull request", + "Creat(?:ed|ing) issue", + "Merged pull request", + "^https?://github\\.com/" + ] + }, + "tests": [ + { + "name": "pr create", + "command": "gh pr create", + "input": "\nCreating pull request for feat/foo into main in owner/repo\n\nhttps://github.com/owner/repo/pull/123\n", + "expected": "Creating pull request for feat/foo into main in owner/repo\nhttps://github.com/owner/repo/pull/123" + }, + { + "name": "auth error", + "command": "gh pr list", + "input": "error: must be authenticated to use this command\n", + "expected": "error: must be authenticated to use this command" + }, + { + "name": "skips gh api (structured JSON output)", + "command": "gh api repos/owner/repo/pulls/1", + "input": "{\n \"number\": 1,\n \"title\": \"feat: x\",\n \"user\": { \"login\": \"alice\" }\n}\n", + "expected": "{\n \"number\": 1,\n \"title\": \"feat: x\",\n \"user\": { \"login\": \"alice\" }\n}" + }, + { + "name": "skips --json output (would corrupt structured data)", + "command": "gh pr list --json number,title", + "input": "[\n { \"number\": 1, \"title\": \"feat: x\" },\n { \"number\": 2, \"title\": \"fix: y\" }\n]\n", + "expected": "[\n { \"number\": 1, \"title\": \"feat: x\" },\n { \"number\": 2, \"title\": \"fix: y\" }\n]" + } + ] +} diff --git a/open-sse/services/compression/engines/rtk/filters/kubectl.json b/open-sse/services/compression/engines/rtk/filters/kubectl.json new file mode 100644 index 0000000000..eeb9c5bea4 --- /dev/null +++ b/open-sse/services/compression/engines/rtk/filters/kubectl.json @@ -0,0 +1,99 @@ +{ + "id": "kubectl", + "label": "kubectl", + "description": "Keep kubectl status, rollout and error output. Skips logs/exec/top (arbitrary content) and structured -o json/yaml output.", + "category": "cloud", + "priority": 78, + "match": { + "outputTypes": ["kubectl"], + "commands": [ + "^kubectl\\s+(?:get|describe|apply|delete|rollout|events?)(?!.*(?:-o|--output)(?:\\s+|=)(?:json|yaml|go-template|jsonpath|template|name))\\b" + ], + "patterns": [ + "NAME\\s+READY\\s+STATUS", + "NAME\\s+TYPE\\s+CLUSTER-IP", + "CrashLoopBackOff", + "ImagePullBackOff", + "ErrImagePull", + "FailedScheduling", + "OOMKilled", + "Error from server" + ] + }, + "rules": { + "stripAnsi": true, + "includePatterns": [ + "Error from server", + "CrashLoopBackOff", + "ImagePullBackOff", + "ErrImagePull", + "FailedScheduling", + "OOMKilled", + "Evicted", + "ContainerCannotRun", + "BackOff", + "Unhealthy", + "Killing", + "Restarting", + "NAME\\s+READY\\s+STATUS", + "NAME\\s+TYPE\\s+CLUSTER-IP", + "Warning\\s+", + "deployment\\.apps/\\S+\\s+(?:created|configured|unchanged)", + "service/\\S+\\s+(?:created|configured)", + "pod/\\S+\\s+(?:created|deleted)", + "rollout (?:status|history)", + "successfully rolled out" + ], + "dropPatterns": [ + "^\\s*$", + "^\\s*creationTimestamp:", + "^\\s*resourceVersion:", + "^\\s*uid:", + "^\\s*managedFields:", + "^\\s*selfLink:", + "^\\s*generation:" + ], + "collapsePatterns": ["Normal\\s+", "^\\s*-\\s+lastTransitionTime:"], + "deduplicate": true, + "maxLines": 120, + "headLines": 20, + "tailLines": 60 + }, + "preserve": { + "errorPatterns": [ + "Error from server", + "CrashLoopBackOff", + "ImagePullBackOff", + "OOMKilled", + "FailedScheduling", + "Evicted" + ], + "summaryPatterns": ["successfully rolled out", "deployment\\.apps/\\S+\\s+\\w+"] + }, + "tests": [ + { + "name": "pod listing with failure", + "command": "kubectl get", + "input": "NAME READY STATUS RESTARTS AGE\napi-7d8 1/1 Running 0 2h\nweb-2c4 0/1 CrashLoopBackOff 5 12m\ndb-9f1 1/1 Running 0 2h\n", + "expected": "NAME READY STATUS RESTARTS AGE\nweb-2c4 0/1 CrashLoopBackOff 5 12m" + }, + { + "name": "rollout success", + "command": "kubectl rollout status", + "input": "Waiting for deployment \"api\" rollout to finish: 0 of 3 updated replicas are available...\nWaiting for deployment \"api\" rollout to finish: 1 of 3 updated replicas are available...\ndeployment \"api\" successfully rolled out\n", + "expected": "deployment \"api\" successfully rolled out" + }, + { + "name": "skips kubectl logs (arbitrary application output)", + "command": "kubectl logs api-pod", + "input": "2026-05-28T10:00:00 INFO request handled\n2026-05-28T10:00:01 INFO request handled\n", + "expected": "2026-05-28T10:00:00 INFO request handled\n2026-05-28T10:00:01 INFO request handled" + }, + { + "name": "skips json output (would corrupt structured data)", + "command": "kubectl get pods -o json", + "input": "{\n \"apiVersion\": \"v1\",\n \"items\": [\n { \"metadata\": { \"name\": \"api\" } }\n ]\n}\n", + "expected": "{\n \"apiVersion\": \"v1\",\n \"items\": [\n { \"metadata\": { \"name\": \"api\" } }\n ]\n}" + } + ] +} diff --git a/tests/unit/rtk-new-filters.test.ts b/tests/unit/rtk-new-filters.test.ts new file mode 100644 index 0000000000..e3aa4e4471 --- /dev/null +++ b/tests/unit/rtk-new-filters.test.ts @@ -0,0 +1,119 @@ +import { describe, it } from "node:test"; +import assert from "node:assert/strict"; + +import { + loadRtkFilters, + matchRtkFilter, + runRtkFilterTests, +} from "../../open-sse/services/compression/index.ts"; + +const NEW_FILTERS = ["kubectl", "docker-build", "composer", "gh"] as const; + +const MATCH_CASES: Array<[string, string, string]> = [ + [ + "kubectl", + "NAME READY STATUS RESTARTS AGE\nweb-2c4 0/1 CrashLoopBackOff 5 12m", + "kubectl get pods", + ], + [ + "docker-build", + "Step 1/2 : FROM node:20\nSuccessfully built abc123", + "docker build -t myapp .", + ], + [ + "composer", + "Package operations: 5 installs, 0 updates, 0 removals\nGenerating optimized autoload files", + "composer install", + ], + [ + "gh", + "Creating pull request for feat/foo into main in owner/repo\nhttps://github.com/owner/repo/pull/123", + "gh pr create", + ], +]; + +describe("RTK new-filter catalog (kubectl, docker-build, composer, gh)", () => { + it("loads all 4 new filters from disk", () => { + const filters = loadRtkFilters({ refresh: true, customFiltersEnabled: false }); + const ids = new Set(filters.map((f) => f.id)); + for (const id of NEW_FILTERS) { + assert.ok(ids.has(id), `expected filter "${id}" to be loaded`); + } + }); + + it("matches each new filter from realistic command output", () => { + for (const [expectedId, output, command] of MATCH_CASES) { + const filter = matchRtkFilter(output, command, { customFiltersEnabled: false }); + assert.equal(filter?.id, expectedId, `wrong filter matched for "${command}"`); + } + }); + + it("kubectl skips arbitrary-content commands (logs, exec, top)", () => { + const logOutput = + "2026-05-28T10:00:00 INFO request handled\n2026-05-28T10:00:01 INFO request handled"; + for (const cmd of ["kubectl logs api-pod", "kubectl exec api-pod -- npm test", "kubectl top nodes"]) { + const filter = matchRtkFilter(logOutput, cmd, { customFiltersEnabled: false }); + assert.notEqual( + filter?.id, + "kubectl", + `kubectl filter must not claim "${cmd}" (arbitrary content)` + ); + } + }); + + it("kubectl skips structured -o json/yaml output to avoid corrupting it", () => { + const jsonOutput = '{\n "apiVersion": "v1",\n "items": []\n}'; + for (const cmd of [ + "kubectl get pods -o json", + "kubectl get pods -o yaml", + "kubectl get pods --output=json", + "kubectl get svc -o jsonpath='{.items[*].metadata.name}'", + ]) { + const filter = matchRtkFilter(jsonOutput, cmd, { customFiltersEnabled: false }); + assert.notEqual( + filter?.id, + "kubectl", + `kubectl filter must not claim structured output: "${cmd}"` + ); + } + }); + + it("gh skips `gh api` and --json invocations (structured output)", () => { + const jsonOutput = '{\n "number": 1,\n "title": "feat: x"\n}'; + for (const cmd of [ + "gh api repos/owner/repo/pulls/1", + "gh api graphql -f query=...", + "gh pr list --json number,title", + "gh issue list --json number,title,author", + ]) { + const filter = matchRtkFilter(jsonOutput, cmd, { customFiltersEnabled: false }); + assert.notEqual( + filter?.id, + "gh", + `gh filter must not claim structured output: "${cmd}"` + ); + } + }); + + it("docker-build matches both v2 (docker compose) and legacy (docker-compose) build", () => { + const output = "Step 1/2 : FROM node:20\nSuccessfully built abc123"; + for (const cmd of ["docker build .", "docker compose build", "docker-compose build", "docker buildx build ."]) { + const filter = matchRtkFilter(output, cmd, { customFiltersEnabled: false }); + assert.equal(filter?.id, "docker-build", `expected docker-build to match "${cmd}"`); + } + }); + + it("inline tests for new filters all pass", () => { + const result = runRtkFilterTests({ requireAll: false, customFiltersEnabled: false }); + const newFilterOutcomes = result.outcomes.filter((o) => + NEW_FILTERS.includes(o.filterId as (typeof NEW_FILTERS)[number]) + ); + assert.ok(newFilterOutcomes.length > 0, "expected inline tests from new filters to run"); + const failed = newFilterOutcomes.filter((o) => !o.passed); + assert.deepEqual( + failed, + [], + `inline tests failed: ${failed.map((f) => `${f.filterId}/${f.testName}`).join(", ")}` + ); + }); +});