mirror of
https://github.com/lukaszraczylo/llm-testbench.git
synced 2026-10-12 00:45:19 +00:00
test(catalog): expand operations to 10 tests per subcategory
Add 8 macOS tests, 8 Linux tests, and 9 Kubernetes tests to internal/tests. Each operations subcategory now holds 10 deterministic tests, up from 2, 2, and 1. Tests cover BSD-versus-GNU tooling, launchd, Proxmox pct exec, systemd timers, cgroup v2 diagnosis, and ArgoCD GitOps scenarios. Evaluators use ContainsAll, ContainsAny, Regex, JSONField, Numeric, and weighted composites, matching the existing catalog style. Every evaluator unit test asserts three correct phrasings and two wrong ones, per the expansion contract. Ground truth for numeric and ordered answers carries a derivation comment. catalog_test.go still expects 17 tests and fails as documented. This commit does not modify that file.
This commit is contained in:
@@ -11,6 +11,15 @@ import (
|
|||||||
|
|
||||||
func registerKubernetesTests(r *testkit.Registry) {
|
func registerKubernetesTests(r *testkit.Registry) {
|
||||||
r.Register(k8sCrashloopGitopsTest())
|
r.Register(k8sCrashloopGitopsTest())
|
||||||
|
r.Register(k8sImagePullBackoffTest())
|
||||||
|
r.Register(k8sPendingTaintsTest())
|
||||||
|
r.Register(k8sPVCStorageclassMismatchTest())
|
||||||
|
r.Register(k8sServiceSelectorMismatchTest())
|
||||||
|
r.Register(k8sNetworkPolicyDNSBlockTest())
|
||||||
|
r.Register(k8sArgoCDOutOfSyncTest())
|
||||||
|
r.Register(k8sTraefikIngressRouteHostTest())
|
||||||
|
r.Register(k8sQoSClassTest())
|
||||||
|
r.Register(k8sCNPGPDBTest())
|
||||||
}
|
}
|
||||||
|
|
||||||
// k8sCrashloopGitopsDescribeExcerpt is the inline kubectl describe pod
|
// k8sCrashloopGitopsDescribeExcerpt is the inline kubectl describe pod
|
||||||
@@ -137,3 +146,454 @@ procedure to fix it given that ArgoCD manages this cluster?`
|
|||||||
Eval: evaluator,
|
Eval: evaluator,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// k8sImagePullBackoffDescribeExcerpt is the inline kubectl describe pod
|
||||||
|
// excerpt for k8sImagePullBackoffTest.
|
||||||
|
const k8sImagePullBackoffDescribeExcerpt = `Name: checkout-api-7d8f9c6b5d-4mnbv
|
||||||
|
Namespace: checkout
|
||||||
|
Status: Pending
|
||||||
|
Containers:
|
||||||
|
api:
|
||||||
|
Image: registry.internal.example.com/checkout-api:v1.2.3
|
||||||
|
State: Waiting
|
||||||
|
Reason: ImagePullBackOff
|
||||||
|
Events:
|
||||||
|
Warning Failed kubelet Failed to pull image "registry.internal.example.com/checkout-api:v1.2.3": rpc error: code = Unknown desc = failed to pull and unpack image: failed to resolve reference: unexpected status code 401 Unauthorized: authentication required
|
||||||
|
Warning Failed kubelet Error: ErrImagePull
|
||||||
|
Normal BackOff kubelet Back-off pulling image "registry.internal.example.com/checkout-api:v1.2.3"`
|
||||||
|
|
||||||
|
// k8sImagePullBackoffTest: classify an ImagePullBackOff's root cause from
|
||||||
|
// the HTTP status embedded in the pull error, and name the matching fix.
|
||||||
|
//
|
||||||
|
// ground truth: the pull error is "401 Unauthorized: authentication
|
||||||
|
// required" - the registry rejected the request for lacking credentials,
|
||||||
|
// not because the image/tag is missing (that would be a 404/"not found")
|
||||||
|
// or because the registry is unreachable (that would be a network/DNS/TLS
|
||||||
|
// error). The fix is to give the pod credentials for the private registry
|
||||||
|
// via imagePullSecrets, not to touch the image tag or check connectivity.
|
||||||
|
func k8sImagePullBackoffTest() testkit.Test {
|
||||||
|
prompt := `Here is the output of "kubectl describe pod" for a pod stuck
|
||||||
|
in ImagePullBackOff:
|
||||||
|
|
||||||
|
` + k8sImagePullBackoffDescribeExcerpt + `
|
||||||
|
|
||||||
|
What category of root cause is this, and what category of fix addresses
|
||||||
|
it? Respond with only a JSON object:
|
||||||
|
{"cause":"<one of: auth, not-found, network>","fix":"<one of: imagePullSecrets, fix-image-tag, check-network>"}`
|
||||||
|
|
||||||
|
evaluator := eval.Mean(
|
||||||
|
eval.JSONField("cause", "auth"),
|
||||||
|
eval.JSONField("fix", "imagePullSecrets"),
|
||||||
|
)
|
||||||
|
|
||||||
|
return testkit.Test{
|
||||||
|
ID: "k8s-imagepullbackoff",
|
||||||
|
Category: "operations",
|
||||||
|
Subcategory: "kubernetes",
|
||||||
|
Description: "Classify an ImagePullBackOff's root cause (401 auth failure, not a missing tag or network issue) and its fix category.",
|
||||||
|
Prompt: prompt,
|
||||||
|
Eval: evaluator,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// k8sPendingTaintsDescribeExcerpt is the inline kubectl describe pod
|
||||||
|
// excerpt for k8sPendingTaintsTest.
|
||||||
|
const k8sPendingTaintsDescribeExcerpt = `Name: batch-etl-59c8d8c9f7-k2p8q
|
||||||
|
Namespace: data
|
||||||
|
Status: Pending
|
||||||
|
Spec (excerpt):
|
||||||
|
tolerations: <none>
|
||||||
|
nodeSelector: <none>
|
||||||
|
Events:
|
||||||
|
Warning FailedScheduling default-scheduler 0/3 nodes are available: 3 node(s) had untolerated taint {dedicated: gpu-only: NoSchedule}. preemption: 0/3 nodes are available: 3 Preemption is not helpful for scheduling.`
|
||||||
|
|
||||||
|
// k8sPendingTaintsTest: identify an untolerated node taint as the reason a
|
||||||
|
// pod cannot be scheduled, and name the pod-spec field that fixes it.
|
||||||
|
//
|
||||||
|
// ground truth: the FailedScheduling event names the exact blocker -
|
||||||
|
// "untolerated taint {dedicated: gpu-only: NoSchedule}" on all 3
|
||||||
|
// candidate nodes - and the pod spec confirms it has no tolerations at
|
||||||
|
// all. Adding a matching entry under the pod's tolerations field (not
|
||||||
|
// nodeSelector, which restricts placement rather than tolerating a taint,
|
||||||
|
// and not resources, which is unrelated) is the fix.
|
||||||
|
func k8sPendingTaintsTest() testkit.Test {
|
||||||
|
prompt := `Here is the output of "kubectl describe pod" for a pod stuck
|
||||||
|
Pending:
|
||||||
|
|
||||||
|
` + k8sPendingTaintsDescribeExcerpt + `
|
||||||
|
|
||||||
|
Why can the scheduler not place this pod on any of the 3 candidate nodes,
|
||||||
|
and which pod spec field must be added to fix it? Respond with only a
|
||||||
|
JSON object:
|
||||||
|
{"cause":"<one of: taint, resource-limit, affinity>","fix_field":"<one of: tolerations, nodeSelector, resources>"}`
|
||||||
|
|
||||||
|
evaluator := eval.Mean(
|
||||||
|
eval.JSONField("cause", "taint"),
|
||||||
|
eval.JSONField("fix_field", "tolerations"),
|
||||||
|
)
|
||||||
|
|
||||||
|
return testkit.Test{
|
||||||
|
ID: "k8s-pending-taints",
|
||||||
|
Category: "operations",
|
||||||
|
Subcategory: "kubernetes",
|
||||||
|
Description: "Identify an untolerated node taint blocking scheduling and the tolerations field that fixes it.",
|
||||||
|
Prompt: prompt,
|
||||||
|
Eval: evaluator,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// k8sPVCStorageclassExcerpt is the inline kubectl describe pvc + kubectl
|
||||||
|
// get storageclass excerpt for k8sPVCStorageclassMismatchTest.
|
||||||
|
const k8sPVCStorageclassExcerpt = `$ kubectl describe pvc data-pvc -n analytics
|
||||||
|
Name: data-pvc
|
||||||
|
Namespace: analytics
|
||||||
|
StorageClass: fast-ssd
|
||||||
|
Status: Pending
|
||||||
|
Events:
|
||||||
|
Warning ProvisioningFailed persistentvolume-controller storageclass.storage.k8s.io "fast-ssd" not found
|
||||||
|
|
||||||
|
$ kubectl get storageclass
|
||||||
|
NAME PROVISIONER
|
||||||
|
standard csi.example.com`
|
||||||
|
|
||||||
|
// k8sPVCStorageclassMismatchTest: diagnose a Pending PVC referencing a
|
||||||
|
// StorageClass that does not exist in the cluster, and pick the existing
|
||||||
|
// class to switch to.
|
||||||
|
//
|
||||||
|
// ground truth: the ProvisioningFailed event states plainly that
|
||||||
|
// StorageClass "fast-ssd" does not exist; "kubectl get storageclass" shows
|
||||||
|
// only "standard" exists. Since this cluster's StorageClasses are managed
|
||||||
|
// centrally via GitOps and creating one ad hoc is ruled out by the prompt,
|
||||||
|
// the only available fix is to point the PVC at the StorageClass that
|
||||||
|
// already exists: "standard".
|
||||||
|
func k8sPVCStorageclassMismatchTest() testkit.Test {
|
||||||
|
prompt := `This cluster's StorageClasses are defined centrally via
|
||||||
|
GitOps; creating a new StorageClass ad hoc is not an option here. Here is
|
||||||
|
the PVC and the StorageClasses that actually exist in the cluster:
|
||||||
|
|
||||||
|
` + k8sPVCStorageclassExcerpt + `
|
||||||
|
|
||||||
|
What caused this PVC to stay Pending, and which existing StorageClass name
|
||||||
|
should it be changed to reference so it can be provisioned? Respond with
|
||||||
|
only a JSON object:
|
||||||
|
{"cause":"<one of: missing-storageclass, provisioner-down, quota-exceeded>","use_storageclass":"<name>"}`
|
||||||
|
|
||||||
|
evaluator := eval.Mean(
|
||||||
|
eval.JSONField("cause", "missing-storageclass"),
|
||||||
|
eval.JSONField("use_storageclass", "standard"),
|
||||||
|
)
|
||||||
|
|
||||||
|
return testkit.Test{
|
||||||
|
ID: "k8s-pvc-storageclass-mismatch",
|
||||||
|
Category: "operations",
|
||||||
|
Subcategory: "kubernetes",
|
||||||
|
Description: "Diagnose a Pending PVC referencing a nonexistent StorageClass and pick the one existing class to use instead.",
|
||||||
|
Prompt: prompt,
|
||||||
|
Eval: evaluator,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// k8sServiceSelectorMismatchYAML is the inline Deployment + Service YAML
|
||||||
|
// excerpt for k8sServiceSelectorMismatchTest.
|
||||||
|
const k8sServiceSelectorMismatchYAML = `# Deployment (excerpt)
|
||||||
|
apiVersion: apps/v1
|
||||||
|
kind: Deployment
|
||||||
|
metadata:
|
||||||
|
name: inventory-api
|
||||||
|
namespace: inventory
|
||||||
|
spec:
|
||||||
|
template:
|
||||||
|
metadata:
|
||||||
|
labels:
|
||||||
|
app: inventory-api
|
||||||
|
tier: backend
|
||||||
|
|
||||||
|
---
|
||||||
|
# Service
|
||||||
|
apiVersion: v1
|
||||||
|
kind: Service
|
||||||
|
metadata:
|
||||||
|
name: inventory-api
|
||||||
|
namespace: inventory
|
||||||
|
spec:
|
||||||
|
selector:
|
||||||
|
app: inventory-api-svc
|
||||||
|
ports:
|
||||||
|
- port: 80
|
||||||
|
targetPort: 8080`
|
||||||
|
|
||||||
|
// k8sServiceSelectorMismatchTest: spot a Service selector that does not
|
||||||
|
// match the Deployment's pod-template labels, leaving the Service with no
|
||||||
|
// Endpoints.
|
||||||
|
//
|
||||||
|
// ground truth: the pods carry label app=inventory-api (from the
|
||||||
|
// Deployment's pod template), but the Service's selector is
|
||||||
|
// app=inventory-api-svc - a value that matches no pod's labels, so the
|
||||||
|
// Service has zero Endpoints. The single wrong field is the Service's
|
||||||
|
// spec.selector.app value; it must be changed to "inventory-api" to match
|
||||||
|
// the pods.
|
||||||
|
func k8sServiceSelectorMismatchTest() testkit.Test {
|
||||||
|
prompt := `Requests to this Service time out with no available
|
||||||
|
endpoints. Here is the Deployment's pod template and the Service:
|
||||||
|
|
||||||
|
` + k8sServiceSelectorMismatchYAML + `
|
||||||
|
|
||||||
|
Which single field in the Service YAML is wrong, and what value should it
|
||||||
|
be changed to so the Service's Endpoints include these pods? Respond with
|
||||||
|
only a JSON object: {"field":"selector.app","value":"<correct value>"}`
|
||||||
|
|
||||||
|
evaluator := eval.Mean(
|
||||||
|
eval.JSONField("field", "selector.app"),
|
||||||
|
eval.JSONField("value", "inventory-api"),
|
||||||
|
)
|
||||||
|
|
||||||
|
return testkit.Test{
|
||||||
|
ID: "k8s-service-selector-mismatch",
|
||||||
|
Category: "operations",
|
||||||
|
Subcategory: "kubernetes",
|
||||||
|
Description: "Spot a Service selector.app value that does not match the Deployment's pod-template labels.",
|
||||||
|
Prompt: prompt,
|
||||||
|
Eval: evaluator,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// k8sNetworkPolicyDNSYAML is the inline default-deny-egress NetworkPolicy
|
||||||
|
// excerpt for k8sNetworkPolicyDNSBlockTest.
|
||||||
|
const k8sNetworkPolicyDNSYAML = `apiVersion: networking.k8s.io/v1
|
||||||
|
kind: NetworkPolicy
|
||||||
|
metadata:
|
||||||
|
name: payments-egress
|
||||||
|
namespace: payments
|
||||||
|
spec:
|
||||||
|
podSelector:
|
||||||
|
matchLabels:
|
||||||
|
app: payments
|
||||||
|
policyTypes:
|
||||||
|
- Egress
|
||||||
|
egress:
|
||||||
|
- to:
|
||||||
|
- podSelector:
|
||||||
|
matchLabels:
|
||||||
|
app: payments-db
|
||||||
|
ports:
|
||||||
|
- protocol: TCP
|
||||||
|
port: 5432`
|
||||||
|
|
||||||
|
// k8sNetworkPolicyDNSBlockTest: explain why a default-deny-egress
|
||||||
|
// NetworkPolicy that only allows one destination breaks DNS resolution,
|
||||||
|
// and what egress rule fixes it.
|
||||||
|
//
|
||||||
|
// ground truth: this NetworkPolicy selects pods labeled app=payments,
|
||||||
|
// applies to Egress, and lists exactly one allowed destination (the
|
||||||
|
// payments-db pods on 5432/TCP) - meaning every other egress, including
|
||||||
|
// DNS lookups to CoreDNS/kube-dns on port 53, is implicitly denied. The
|
||||||
|
// fix is an additional egress rule allowing UDP (and typically TCP) port
|
||||||
|
// 53 to the cluster's DNS pods (conventionally labeled k8s-app=kube-dns in
|
||||||
|
// the kube-system namespace).
|
||||||
|
func k8sNetworkPolicyDNSBlockTest() testkit.Test {
|
||||||
|
prompt := `Pods labeled app=payments in the payments namespace can reach
|
||||||
|
the payments database but cannot resolve ANY DNS name, including external
|
||||||
|
ones. Here is the only NetworkPolicy selecting these pods:
|
||||||
|
|
||||||
|
` + k8sNetworkPolicyDNSYAML + `
|
||||||
|
|
||||||
|
Why does this NetworkPolicy break DNS resolution for these pods, and what
|
||||||
|
additional egress rule (name the port, protocol, and typical target label)
|
||||||
|
must be added to fix it?`
|
||||||
|
|
||||||
|
evaluator := eval.All(
|
||||||
|
eval.W(eval.ContainsAll("53"), 2),
|
||||||
|
eval.W(eval.ContainsAny("udp", "UDP"), 2),
|
||||||
|
eval.W(eval.ContainsAny("kube-dns", "coredns", "kube-system"), 2),
|
||||||
|
)
|
||||||
|
|
||||||
|
return testkit.Test{
|
||||||
|
ID: "k8s-networkpolicy-dns-block",
|
||||||
|
Category: "operations",
|
||||||
|
Subcategory: "kubernetes",
|
||||||
|
Description: "Explain why a single-destination-allow egress NetworkPolicy implicitly blocks DNS, and the port-53/UDP fix.",
|
||||||
|
Prompt: prompt,
|
||||||
|
Eval: evaluator,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// k8sArgoCDOutOfSyncStatus is the inline "argocd app get" excerpt for
|
||||||
|
// k8sArgoCDOutOfSyncTest.
|
||||||
|
const k8sArgoCDOutOfSyncStatus = `$ argocd app get inventory-api
|
||||||
|
Name: inventory-api
|
||||||
|
Namespace: inventory
|
||||||
|
Health Status: Healthy
|
||||||
|
Sync Status: OutOfSync
|
||||||
|
|
||||||
|
Resources:
|
||||||
|
Kind Name Status
|
||||||
|
Deployment inventory-api OutOfSync
|
||||||
|
|
||||||
|
Diff (live vs target, Deployment/inventory-api):
|
||||||
|
live: spec.replicas: 5
|
||||||
|
target: spec.replicas: 2
|
||||||
|
|
||||||
|
Note: a HorizontalPodAutoscaler named inventory-api-hpa also targets this
|
||||||
|
Deployment and actively manages its replica count based on CPU load.
|
||||||
|
Nobody has manually run "kubectl scale", "kubectl edit", or
|
||||||
|
"argocd app sync" recently.`
|
||||||
|
|
||||||
|
// k8sArgoCDOutOfSyncTest: identify an HPA-managed field as the reason an
|
||||||
|
// ArgoCD Application stays OutOfSync, and the idiomatic fix.
|
||||||
|
//
|
||||||
|
// ground truth: the only diff is spec.replicas, and an HPA legitimately
|
||||||
|
// changes that field outside of git as it scales the Deployment - so the
|
||||||
|
// Application will drift back OutOfSync after every "argocd app sync"
|
||||||
|
// without addressing the underlying cause. ArgoCD's own mechanism for this
|
||||||
|
// exact situation is configuring spec.ignoreDifferences on the Application
|
||||||
|
// for the HPA-managed field, not repeatedly re-syncing or reverting a
|
||||||
|
// change nobody made.
|
||||||
|
func k8sArgoCDOutOfSyncTest() testkit.Test {
|
||||||
|
prompt := `Here is the status of an ArgoCD Application that keeps
|
||||||
|
showing OutOfSync:
|
||||||
|
|
||||||
|
` + k8sArgoCDOutOfSyncStatus + `
|
||||||
|
|
||||||
|
Why is this Application stuck OutOfSync on spec.replicas specifically, and
|
||||||
|
what is the correct ArgoCD-idiomatic fix so it stops flapping between
|
||||||
|
Synced and OutOfSync? Respond with only a JSON object:
|
||||||
|
{"cause":"<one of: hpa-managed-field, manual-drift, stale-git>","fix":"<one of: ignoreDifferences, revert-manual-change, sync-git>"}`
|
||||||
|
|
||||||
|
evaluator := eval.Mean(
|
||||||
|
eval.JSONField("cause", "hpa-managed-field"),
|
||||||
|
eval.JSONField("fix", "ignoreDifferences"),
|
||||||
|
)
|
||||||
|
|
||||||
|
return testkit.Test{
|
||||||
|
ID: "k8s-argocd-outofsync",
|
||||||
|
Category: "operations",
|
||||||
|
Subcategory: "kubernetes",
|
||||||
|
Description: "Identify an HPA-managed replicas field as the cause of a flapping ArgoCD OutOfSync and the ignoreDifferences fix.",
|
||||||
|
Prompt: prompt,
|
||||||
|
Eval: evaluator,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// k8sTraefikIngressRouteYAML is the inline IngressRoute excerpt for
|
||||||
|
// k8sTraefikIngressRouteHostTest.
|
||||||
|
const k8sTraefikIngressRouteYAML = "apiVersion: traefik.io/v1alpha1\n" +
|
||||||
|
"kind: IngressRoute\n" +
|
||||||
|
"metadata:\n" +
|
||||||
|
" name: app-route\n" +
|
||||||
|
" namespace: web\n" +
|
||||||
|
"spec:\n" +
|
||||||
|
" entryPoints:\n" +
|
||||||
|
" - websecure\n" +
|
||||||
|
" routes:\n" +
|
||||||
|
" - match: Host(`app.example.com`)\n" +
|
||||||
|
" kind: Rule\n" +
|
||||||
|
" services:\n" +
|
||||||
|
" - name: app-svc\n" +
|
||||||
|
" port: 80"
|
||||||
|
|
||||||
|
// k8sTraefikIngressRoutePattern requires the corrected match expression,
|
||||||
|
// with the backtick-quoted domain exactly matching app.raczylo.com.
|
||||||
|
const k8sTraefikIngressRoutePattern = "(?i)host\\(`app\\.raczylo\\.com`\\)"
|
||||||
|
|
||||||
|
// k8sTraefikIngressRouteHostTest: correct a Traefik IngressRoute's Host()
|
||||||
|
// match rule to the actual domain the service must answer for.
|
||||||
|
//
|
||||||
|
// ground truth: the IngressRoute's only match rule is
|
||||||
|
// Host(`app.example.com`), but the domain that must route to this service
|
||||||
|
// is app.raczylo.com - a different rule value entirely, not a change to
|
||||||
|
// entryPoints, the service name, or the port.
|
||||||
|
func k8sTraefikIngressRouteHostTest() testkit.Test {
|
||||||
|
prompt := `This Traefik IngressRoute is not receiving any traffic
|
||||||
|
because it is matching the wrong hostname. The actual domain this service
|
||||||
|
must answer for is app.raczylo.com. Here is the IngressRoute:
|
||||||
|
|
||||||
|
` + "```yaml\n" + k8sTraefikIngressRouteYAML + "\n```" + `
|
||||||
|
|
||||||
|
Give the single corrected "match" rule line (the full match expression,
|
||||||
|
using the same Host() syntax) for this IngressRoute.`
|
||||||
|
|
||||||
|
return testkit.Test{
|
||||||
|
ID: "k8s-traefik-ingressroute-host",
|
||||||
|
Category: "operations",
|
||||||
|
Subcategory: "kubernetes",
|
||||||
|
Description: "Correct a Traefik IngressRoute's Host() match rule to the actual domain the service must serve.",
|
||||||
|
Prompt: prompt,
|
||||||
|
Eval: eval.Regex(k8sTraefikIngressRoutePattern),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// k8sQoSClassPodSpec is the inline pod spec excerpt for k8sQoSClassTest.
|
||||||
|
const k8sQoSClassPodSpec = `apiVersion: v1
|
||||||
|
kind: Pod
|
||||||
|
metadata:
|
||||||
|
name: report-generator
|
||||||
|
spec:
|
||||||
|
containers:
|
||||||
|
- name: report-generator
|
||||||
|
image: registry.internal.example.com/report-generator:v3
|
||||||
|
resources:
|
||||||
|
requests:
|
||||||
|
cpu: "250m"
|
||||||
|
memory: "256Mi"
|
||||||
|
limits:
|
||||||
|
cpu: "500m"
|
||||||
|
memory: "512Mi"`
|
||||||
|
|
||||||
|
// k8sQoSClassTest: derive the scheduler-assigned QoS class from a pod
|
||||||
|
// spec's requests/limits.
|
||||||
|
//
|
||||||
|
// ground truth: every container defines both requests and limits for both
|
||||||
|
// cpu and memory, ruling out BestEffort (which requires no requests or
|
||||||
|
// limits at all). Since requests (250m/256Mi) differ from limits
|
||||||
|
// (500m/512Mi) rather than being exactly equal, the class is Burstable,
|
||||||
|
// not Guaranteed (which additionally requires requests == limits for
|
||||||
|
// every resource on every container).
|
||||||
|
func k8sQoSClassTest() testkit.Test {
|
||||||
|
prompt := `Here is a pod spec:
|
||||||
|
|
||||||
|
` + "```yaml\n" + k8sQoSClassPodSpec + "\n```" + `
|
||||||
|
|
||||||
|
What Quality of Service (QoS) class does the Kubernetes scheduler assign
|
||||||
|
to this pod? Respond with only one word: Guaranteed, Burstable, or
|
||||||
|
BestEffort.`
|
||||||
|
|
||||||
|
return testkit.Test{
|
||||||
|
ID: "k8s-qos-class",
|
||||||
|
Category: "operations",
|
||||||
|
Subcategory: "kubernetes",
|
||||||
|
Description: "Derive a pod's QoS class (Burstable) from requests that are set but not equal to limits.",
|
||||||
|
Prompt: prompt,
|
||||||
|
Eval: eval.Equals("Burstable"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// k8sCNPGPDBTest: pick PodDisruptionBudget with minAvailable as the
|
||||||
|
// mechanism guaranteeing quorum survives voluntary node maintenance for a
|
||||||
|
// CNPG-managed Postgres cluster.
|
||||||
|
func k8sCNPGPDBTest() testkit.Test {
|
||||||
|
prompt := `A CNPG (CloudNativePG)-managed Postgres cluster runs as 3
|
||||||
|
pods (1 primary, 2 replicas) in the shared-resources namespace. The team
|
||||||
|
wants a guarantee that at least 2 of the 3 Postgres pods stay available
|
||||||
|
during voluntary node maintenance (e.g. "kubectl drain"). Which Kubernetes
|
||||||
|
resource type provides this guarantee, and what field on it must be set,
|
||||||
|
with what minimum value, to enforce "at least 2 available"?`
|
||||||
|
|
||||||
|
// ground truth: a PodDisruptionBudget (PDB) is the resource that
|
||||||
|
// bounds voluntary disruptions (like a node drain evicting pods) for a
|
||||||
|
// selected set of pods. Setting its minAvailable field to 2 enforces
|
||||||
|
// that at least 2 of the 3 Postgres pods must remain available before
|
||||||
|
// the eviction API will permit another one to be drained.
|
||||||
|
evaluator := eval.Mean(
|
||||||
|
eval.ContainsAll("PodDisruptionBudget"),
|
||||||
|
eval.ContainsAny("minAvailable: 2", "minAvailable=2", "minAvailable 2", "minAvailable of 2"),
|
||||||
|
)
|
||||||
|
|
||||||
|
return testkit.Test{
|
||||||
|
ID: "k8s-cnpg-pdb",
|
||||||
|
Category: "operations",
|
||||||
|
Subcategory: "kubernetes",
|
||||||
|
Description: "Identify PodDisruptionBudget with minAvailable=2 as the guarantee for a 3-pod CNPG cluster during node drains.",
|
||||||
|
Prompt: prompt,
|
||||||
|
Eval: evaluator,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -109,3 +109,235 @@ is missing. Check the image tag and re-push it.`
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestK8sImagePullBackoffTest_Eval(t *testing.T) {
|
||||||
|
tc := k8sImagePullBackoffTest()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
response string
|
||||||
|
want float64
|
||||||
|
}{
|
||||||
|
{"correct object", `{"cause":"auth","fix":"imagePullSecrets"}`, 1},
|
||||||
|
{"prose then JSON", "This is a 401, an auth failure.\n" + `{"cause":"auth","fix":"imagePullSecrets"}`, 1},
|
||||||
|
{"fenced JSON", "```json\n" + `{"cause":"auth","fix":"imagePullSecrets"}` + "\n```", 1},
|
||||||
|
{"misreads it as a missing tag", `{"cause":"not-found","fix":"fix-image-tag"}`, 0},
|
||||||
|
{"right cause, wrong fix category", `{"cause":"auth","fix":"check-network"}`, 0.5},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := tc.Eval.Evaluate(context.Background(), tt.response)
|
||||||
|
if got.Value != tt.want {
|
||||||
|
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestK8sPendingTaintsTest_Eval(t *testing.T) {
|
||||||
|
tc := k8sPendingTaintsTest()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
response string
|
||||||
|
want float64
|
||||||
|
}{
|
||||||
|
{"correct object", `{"cause":"taint","fix_field":"tolerations"}`, 1},
|
||||||
|
{"prose then JSON", "The nodes have an untolerated taint.\n" + `{"cause":"taint","fix_field":"tolerations"}`, 1},
|
||||||
|
{"fenced JSON", "```json\n" + `{"cause":"taint","fix_field":"tolerations"}` + "\n```", 1},
|
||||||
|
{"misdiagnoses as a resource limit issue", `{"cause":"resource-limit","fix_field":"resources"}`, 0},
|
||||||
|
{"right cause, wrong fix field", `{"cause":"taint","fix_field":"nodeSelector"}`, 0.5},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := tc.Eval.Evaluate(context.Background(), tt.response)
|
||||||
|
if got.Value != tt.want {
|
||||||
|
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestK8sPVCStorageclassMismatchTest_Eval(t *testing.T) {
|
||||||
|
tc := k8sPVCStorageclassMismatchTest()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
response string
|
||||||
|
want float64
|
||||||
|
}{
|
||||||
|
{"correct object", `{"cause":"missing-storageclass","use_storageclass":"standard"}`, 1},
|
||||||
|
{"prose then JSON", "The fast-ssd class does not exist.\n" + `{"cause":"missing-storageclass","use_storageclass":"standard"}`, 1},
|
||||||
|
{"fenced JSON", "```json\n" + `{"cause":"missing-storageclass","use_storageclass":"standard"}` + "\n```", 1},
|
||||||
|
{"misdiagnoses as the provisioner being down", `{"cause":"provisioner-down","use_storageclass":"standard"}`, 0.5},
|
||||||
|
{"right cause but names a class that does not exist", `{"cause":"missing-storageclass","use_storageclass":"fast-ssd"}`, 0.5},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := tc.Eval.Evaluate(context.Background(), tt.response)
|
||||||
|
if got.Value != tt.want {
|
||||||
|
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestK8sServiceSelectorMismatchTest_Eval(t *testing.T) {
|
||||||
|
tc := k8sServiceSelectorMismatchTest()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
response string
|
||||||
|
want float64
|
||||||
|
}{
|
||||||
|
{"correct object", `{"field":"selector.app","value":"inventory-api"}`, 1},
|
||||||
|
{"prose then JSON", "The selector doesn't match the pod labels.\n" + `{"field":"selector.app","value":"inventory-api"}`, 1},
|
||||||
|
{"fenced JSON", "```json\n" + `{"field":"selector.app","value":"inventory-api"}` + "\n```", 1},
|
||||||
|
{"points at the wrong field (targetPort instead of selector)", `{"field":"targetPort","value":"8080"}`, 0},
|
||||||
|
{"right field, wrong replacement value", `{"field":"selector.app","value":"inventory-api-svc"}`, 0.5},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := tc.Eval.Evaluate(context.Background(), tt.response)
|
||||||
|
if got.Value != tt.want {
|
||||||
|
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestK8sNetworkPolicyDNSBlockTest_Eval(t *testing.T) {
|
||||||
|
tc := k8sNetworkPolicyDNSBlockTest()
|
||||||
|
|
||||||
|
good := `This NetworkPolicy allows Egress only to payments-db pods on
|
||||||
|
5432/TCP, so every other egress - including DNS lookups on port 53/UDP -
|
||||||
|
is implicitly denied. Add an egress rule allowing UDP port 53 (and
|
||||||
|
typically TCP 53) to pods labeled k8s-app=kube-dns in kube-system.`
|
||||||
|
goodAlt := `The policy default-denies all egress except the one listed
|
||||||
|
destination, and DNS needs to reach CoreDNS on UDP port 53, which is not
|
||||||
|
allowed here. Add an egress rule permitting port 53/udp to the coredns
|
||||||
|
pods.`
|
||||||
|
goodTerse := `Add an egress allow rule for port 53 UDP targeting the
|
||||||
|
kube-system namespace's kube-dns pods; everything else is denied by
|
||||||
|
default here.`
|
||||||
|
badWrongPort := `Add an egress rule allowing port 80/TCP to the ingress
|
||||||
|
controller.`
|
||||||
|
badWrongProtocol := `Add an egress rule allowing TCP port 53 only to
|
||||||
|
kube-system, since DNS always uses TCP.`
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
response string
|
||||||
|
want float64
|
||||||
|
}{
|
||||||
|
{"good answer, kube-dns phrasing", good, 1},
|
||||||
|
{"good answer, coredns phrasing", goodAlt, 1},
|
||||||
|
{"good answer, terse phrasing", goodTerse, 1},
|
||||||
|
{"wrong port and protocol entirely", badWrongPort, 0},
|
||||||
|
{"right port, missing udp mention", badWrongProtocol, 2.0 / 3.0},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := tc.Eval.Evaluate(context.Background(), tt.response)
|
||||||
|
if got.Value != tt.want {
|
||||||
|
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestK8sArgoCDOutOfSyncTest_Eval(t *testing.T) {
|
||||||
|
tc := k8sArgoCDOutOfSyncTest()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
response string
|
||||||
|
want float64
|
||||||
|
}{
|
||||||
|
{"correct object", `{"cause":"hpa-managed-field","fix":"ignoreDifferences"}`, 1},
|
||||||
|
{"prose then JSON", "The HPA owns replicas here.\n" + `{"cause":"hpa-managed-field","fix":"ignoreDifferences"}`, 1},
|
||||||
|
{"fenced JSON", "```json\n" + `{"cause":"hpa-managed-field","fix":"ignoreDifferences"}` + "\n```", 1},
|
||||||
|
{"wrongly blames manual drift nobody caused", `{"cause":"manual-drift","fix":"revert-manual-change"}`, 0},
|
||||||
|
{"right cause, wrong fix (fighting the HPA by re-syncing)", `{"cause":"hpa-managed-field","fix":"sync-git"}`, 0.5},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := tc.Eval.Evaluate(context.Background(), tt.response)
|
||||||
|
if got.Value != tt.want {
|
||||||
|
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestK8sTraefikIngressRouteHostTest_Eval(t *testing.T) {
|
||||||
|
tc := k8sTraefikIngressRouteHostTest()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
response string
|
||||||
|
want float64
|
||||||
|
}{
|
||||||
|
{"bare corrected line", "match: Host(`app.raczylo.com`)", 1},
|
||||||
|
{"prose wrapped", "The corrected line is:\nmatch: Host(`app.raczylo.com`)", 1},
|
||||||
|
{"uppercase Host keyword variant", "MATCH: HOST(`app.raczylo.com`)", 1},
|
||||||
|
{"still the old domain", "match: Host(`app.example.com`)", 0},
|
||||||
|
{"right function, wrong domain typo", "match: Host(`app.raczlo.com`)", 0},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := tc.Eval.Evaluate(context.Background(), tt.response)
|
||||||
|
if got.Value != tt.want {
|
||||||
|
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestK8sQoSClassTest_Eval(t *testing.T) {
|
||||||
|
tc := k8sQoSClassTest()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
response string
|
||||||
|
want float64
|
||||||
|
}{
|
||||||
|
{"exact word", "Burstable", 1},
|
||||||
|
{"lowercase", "burstable", 1},
|
||||||
|
{"trailing whitespace", "Burstable\n", 1},
|
||||||
|
{"wrong class, mistakes it for Guaranteed", "Guaranteed", 0},
|
||||||
|
{"wrong class, mistakes it for BestEffort", "BestEffort", 0},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := tc.Eval.Evaluate(context.Background(), tt.response)
|
||||||
|
if got.Value != tt.want {
|
||||||
|
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestK8sCNPGPDBTest_Eval(t *testing.T) {
|
||||||
|
tc := k8sCNPGPDBTest()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
response string
|
||||||
|
want float64
|
||||||
|
}{
|
||||||
|
{"correct resource and value", "Use a PodDisruptionBudget with minAvailable: 2.", 1},
|
||||||
|
{"equals-sign phrasing", "A PodDisruptionBudget (PDB) with minAvailable=2 guarantees this.", 1},
|
||||||
|
{"english phrasing of the value", "Create a PodDisruptionBudget with a minAvailable of 2.", 1},
|
||||||
|
{"wrong resource entirely (a NetworkPolicy cannot do this)", "Use a NetworkPolicy with minAvailable: 2.", 0.5},
|
||||||
|
{"right resource, wrong/missing value", "Use a PodDisruptionBudget, but leave the fields at their defaults.", 0.5},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := tc.Eval.Evaluate(context.Background(), tt.response)
|
||||||
|
if got.Value != tt.want {
|
||||||
|
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,14 @@ import (
|
|||||||
func registerLinuxTests(r *testkit.Registry) {
|
func registerLinuxTests(r *testkit.Registry) {
|
||||||
r.Register(linuxPctExecTest())
|
r.Register(linuxPctExecTest())
|
||||||
r.Register(linuxSystemdOneshotTest())
|
r.Register(linuxSystemdOneshotTest())
|
||||||
|
r.Register(linuxJournalctlFilterTest())
|
||||||
|
r.Register(linuxCgroupOOMDiagnosisTest())
|
||||||
|
r.Register(linuxNftablesDNATTest())
|
||||||
|
r.Register(linuxLVMExtendOrderTest())
|
||||||
|
r.Register(linuxSSHPortForwardTest())
|
||||||
|
r.Register(linuxCronExpressionTest())
|
||||||
|
r.Register(linuxSystemdTimerTest())
|
||||||
|
r.Register(linuxProcMeminfoAvailableTest())
|
||||||
}
|
}
|
||||||
|
|
||||||
// linuxPctExecTest: one-line command to check a service inside an LXC
|
// linuxPctExecTest: one-line command to check a service inside an LXC
|
||||||
@@ -65,3 +73,245 @@ contents only.`
|
|||||||
Eval: evaluator,
|
Eval: evaluator,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// linuxJournalctlFilterPattern requires journalctl filtered to unit
|
||||||
|
// nginx.service and a since-window of 2 hours, in the canonical
|
||||||
|
// "-u UNIT --since WINDOW" order (the overwhelmingly common idiom), on a
|
||||||
|
// single line so a scattered mention across paragraphs does not count.
|
||||||
|
const linuxJournalctlFilterPattern = `(?i)\bjournalctl\b[^\n]*?-u\s+nginx\.service\b[^\n]*?--since\s+["']?-?2\s*h(?:ours?)?(?:\s+ago)?["']?`
|
||||||
|
|
||||||
|
// linuxJournalctlFilterTest: one-line journalctl command filtered to one
|
||||||
|
// unit over a relative time window.
|
||||||
|
func linuxJournalctlFilterTest() testkit.Test {
|
||||||
|
prompt := `Give a single, one-line journalctl command that shows only
|
||||||
|
log entries for the systemd unit "nginx.service" from the last 2 hours.`
|
||||||
|
|
||||||
|
return testkit.Test{
|
||||||
|
ID: "linux-journalctl-filter",
|
||||||
|
Category: "operations",
|
||||||
|
Subcategory: "linux",
|
||||||
|
Description: "One-line journalctl command filtered by unit and a relative 2-hour time window.",
|
||||||
|
Prompt: prompt,
|
||||||
|
Eval: eval.Regex(linuxJournalctlFilterPattern),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// linuxCgroupMemoryEvents is the inline cgroup v2 memory.events fixture for
|
||||||
|
// linuxCgroupOOMDiagnosisTest.
|
||||||
|
const linuxCgroupMemoryEvents = `low 0
|
||||||
|
high 0
|
||||||
|
max 47
|
||||||
|
oom 3
|
||||||
|
oom_kill 3`
|
||||||
|
|
||||||
|
// linuxCgroupOOMDiagnosisTest: read a cgroup v2 memory.events file and
|
||||||
|
// report whether the kernel actually OOM-killed the cgroup's processes.
|
||||||
|
//
|
||||||
|
// ground truth: cgroup v2's memory.events "oom" counts memory allocation
|
||||||
|
// failures that triggered the OOM killer to run, while "oom_kill" counts
|
||||||
|
// processes the kernel actually killed as a result. Here oom_kill is 3
|
||||||
|
// (not 0), so the cgroup's processes were in fact OOM-killed, 3 times.
|
||||||
|
func linuxCgroupOOMDiagnosisTest() testkit.Test {
|
||||||
|
prompt := `Here is the contents of a container's cgroup v2
|
||||||
|
memory.events file:
|
||||||
|
|
||||||
|
` + linuxCgroupMemoryEvents + `
|
||||||
|
|
||||||
|
Based only on this file, was any process in this cgroup actually killed by
|
||||||
|
the kernel's OOM killer, and if so, how many times? Respond with only a
|
||||||
|
JSON object: {"oom_killed": true or false, "oom_kill_count": <number>}.`
|
||||||
|
|
||||||
|
evaluator := eval.Mean(
|
||||||
|
eval.JSONField("oom_killed", true),
|
||||||
|
eval.JSONField("oom_kill_count", 3),
|
||||||
|
)
|
||||||
|
|
||||||
|
return testkit.Test{
|
||||||
|
ID: "linux-cgroup-oom-diagnosis",
|
||||||
|
Category: "operations",
|
||||||
|
Subcategory: "linux",
|
||||||
|
Description: "Diagnose an actual OOM-kill count from cgroup v2 memory.events (oom_kill, not the broader oom counter).",
|
||||||
|
Prompt: prompt,
|
||||||
|
Eval: evaluator,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// linuxNftablesDNATPattern requires a DNAT rule (iptables or nft form) on
|
||||||
|
// the nat table's prerouting chain, matching TCP port 8080 and forwarding
|
||||||
|
// to 10.0.5.20:80, on a single line. iptables and nft flags/keywords follow
|
||||||
|
// a fixed canonical order (table/chain, then match, then target), so this
|
||||||
|
// uses the same non-greedy same-line anchoring as the pct-exec lesson
|
||||||
|
// rather than a permutation of independently-orderable flags.
|
||||||
|
const linuxNftablesDNATPattern = `(?i)(?:iptables|nft)\b[^\n]*?nat[^\n]*?prerouting[^\n]*?(?:--dport|dport)\s*8080[^\n]*?dnat[^\n]*?10\.0\.5\.20:80`
|
||||||
|
|
||||||
|
// linuxNftablesDNATTest: one-line DNAT rule forwarding an external port to
|
||||||
|
// an internal host:port.
|
||||||
|
func linuxNftablesDNATTest() testkit.Test {
|
||||||
|
prompt := `Give a single, one-line firewall command (iptables or nft,
|
||||||
|
your choice) that DNATs incoming TCP traffic on this host's eth0 interface,
|
||||||
|
port 8080, to internal address 10.0.5.20, port 80.`
|
||||||
|
|
||||||
|
return testkit.Test{
|
||||||
|
ID: "linux-nftables-dnat",
|
||||||
|
Category: "operations",
|
||||||
|
Subcategory: "linux",
|
||||||
|
Description: "One-line DNAT rule (iptables or nft) forwarding TCP 8080 on eth0 to 10.0.5.20:80.",
|
||||||
|
Prompt: prompt,
|
||||||
|
Eval: eval.Regex(linuxNftablesDNATPattern),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// linuxLVMExtendWant is the unique correct 4-step LVM growth sequence for
|
||||||
|
// linuxLVMExtendOrderTest.
|
||||||
|
var linuxLVMExtendWant = []string{"pvcreate", "vgextend", "lvextend", "resize2fs"}
|
||||||
|
|
||||||
|
// linuxLVMExtendOrderTest: order the LVM commands that grow a logical
|
||||||
|
// volume and its filesystem onto a newly added disk.
|
||||||
|
//
|
||||||
|
// ground truth: a new disk must first become an LVM physical volume
|
||||||
|
// (pvcreate), then be added to the volume group (vgextend), before the
|
||||||
|
// logical volume can grow into that new space (lvextend), after which the
|
||||||
|
// filesystem itself must be grown to use it (resize2fs, for ext4). mkfs is
|
||||||
|
// never used here - it would format over, and destroy, the existing data.
|
||||||
|
func linuxLVMExtendOrderTest() testkit.Test {
|
||||||
|
prompt := `The logical volume data-lv on volume group data-vg (ext4
|
||||||
|
filesystem) is full. You have already partitioned a newly added disk as
|
||||||
|
/dev/sdb1, but done nothing else with it.
|
||||||
|
|
||||||
|
From this list of steps - pvcreate, vgextend, lvextend, resize2fs, mkfs -
|
||||||
|
choose exactly the 4 steps needed, in the correct order, to grow data-lv
|
||||||
|
and its filesystem onto the new space without destroying the existing
|
||||||
|
data. Respond with only a JSON array of the step ids, e.g.
|
||||||
|
["step1","step2",...].`
|
||||||
|
|
||||||
|
return testkit.Test{
|
||||||
|
ID: "linux-lvm-extend-order",
|
||||||
|
Category: "operations",
|
||||||
|
Subcategory: "linux",
|
||||||
|
Description: "Order the 4 LVM/filesystem commands (of 5 candidates) that grow a logical volume onto a newly added disk.",
|
||||||
|
Prompt: prompt,
|
||||||
|
Eval: eval.JSONStringArrayEquals(linuxLVMExtendWant),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// linuxSSHPortForwardPattern requires an ssh local port-forward (-L) from
|
||||||
|
// local port 15432 to the remote Postgres, connecting to the correct host.
|
||||||
|
const linuxSSHPortForwardPattern = `(?i)\bssh\b[^\n]*?-L\s*15432:(?:127\.0\.0\.1|localhost):5432[^\n]*?ops@db1\.internal`
|
||||||
|
|
||||||
|
// linuxSSHPortForwardTest: ssh -L local port-forward command derived from a
|
||||||
|
// scenario.
|
||||||
|
func linuxSSHPortForwardTest() testkit.Test {
|
||||||
|
prompt := `A Postgres database only listens on 127.0.0.1:5432 on remote
|
||||||
|
host db1.internal, which you can reach over SSH as user "ops". Give a
|
||||||
|
single, one-line ssh command, run from your laptop, that forwards your
|
||||||
|
laptop's local port 15432 to that remote Postgres instance, so a local
|
||||||
|
client can connect to localhost:15432.`
|
||||||
|
|
||||||
|
return testkit.Test{
|
||||||
|
ID: "linux-ssh-port-forward",
|
||||||
|
Category: "operations",
|
||||||
|
Subcategory: "linux",
|
||||||
|
Description: "One-line ssh -L local port-forward command reaching a Postgres instance that only listens on remote loopback.",
|
||||||
|
Prompt: prompt,
|
||||||
|
Eval: eval.Regex(linuxSSHPortForwardPattern),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// linuxCronExpressionPattern requires the exact 5-field cron schedule for
|
||||||
|
// "03:30 every Monday", tolerant only of whitespace width between fields -
|
||||||
|
// unlike eval.Equals, this stays correct even if the model adds a leading
|
||||||
|
// or trailing space, since the fields themselves have no valid rephrasing.
|
||||||
|
const linuxCronExpressionPattern = `^\s*30\s+3\s+\*\s+\*\s+1\s*$`
|
||||||
|
|
||||||
|
// linuxCronExpressionTest: produce the 5-field cron expression matching a
|
||||||
|
// stated schedule.
|
||||||
|
//
|
||||||
|
// ground truth: cron fields are minute hour day-of-month month
|
||||||
|
// day-of-week. 03:30 is minute=30, hour=3; day-of-month and month are
|
||||||
|
// unrestricted (*); Monday is day-of-week 1 in standard cron numbering
|
||||||
|
// (0 and 7 both mean Sunday).
|
||||||
|
func linuxCronExpressionTest() testkit.Test {
|
||||||
|
prompt := `Give the single 5-field cron expression that runs a job at
|
||||||
|
03:30 every Monday only. Use numeric day-of-week (Monday = 1). Respond
|
||||||
|
with only the expression, nothing else.`
|
||||||
|
|
||||||
|
return testkit.Test{
|
||||||
|
ID: "linux-cron-expression",
|
||||||
|
Category: "operations",
|
||||||
|
Subcategory: "linux",
|
||||||
|
Description: "Produce the 5-field cron expression for a 03:30-every-Monday schedule.",
|
||||||
|
Prompt: prompt,
|
||||||
|
Eval: eval.Regex(linuxCronExpressionPattern),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// linuxSystemdTimerTest: name the systemd timer+service unit pair (not
|
||||||
|
// cron) for a fixed daily schedule, with the required timer keys.
|
||||||
|
func linuxSystemdTimerTest() testkit.Test {
|
||||||
|
prompt := `A backup script must run every day at 02:00, managed by
|
||||||
|
systemd (not cron), using a timer unit plus a service unit. Name the two
|
||||||
|
unit file types this requires, give the key line in the .timer file that
|
||||||
|
sets the daily 02:00 schedule, and give the [Install] target the .timer
|
||||||
|
unit's WantedBy needs.`
|
||||||
|
|
||||||
|
// ground truth: a systemd timer job is a .timer unit (the schedule)
|
||||||
|
// paired with a same-named .service unit (the work). The .timer's
|
||||||
|
// OnCalendar= key sets the schedule (e.g. "*-*-* 02:00:00" for daily at
|
||||||
|
// 02:00), and its [Install] section needs WantedBy=timers.target for
|
||||||
|
// systemctl enable to activate it.
|
||||||
|
evaluator := eval.Mean(
|
||||||
|
eval.ContainsAll(".service", ".timer"),
|
||||||
|
eval.ContainsAny("OnCalendar"),
|
||||||
|
eval.ContainsAll("WantedBy=timers.target"),
|
||||||
|
)
|
||||||
|
|
||||||
|
return testkit.Test{
|
||||||
|
ID: "linux-systemd-timer",
|
||||||
|
Category: "operations",
|
||||||
|
Subcategory: "linux",
|
||||||
|
Description: "Name the systemd .timer/.service unit pair, OnCalendar schedule key, and timers.target for a daily job.",
|
||||||
|
Prompt: prompt,
|
||||||
|
Eval: evaluator,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// linuxProcMeminfo is the inline /proc/meminfo fixture for
|
||||||
|
// linuxProcMeminfoAvailableTest.
|
||||||
|
const linuxProcMeminfo = `MemTotal: 16384000 kB
|
||||||
|
MemFree: 1024000 kB
|
||||||
|
MemAvailable: 9876000 kB
|
||||||
|
Buffers: 512000 kB
|
||||||
|
Cached: 3200000 kB`
|
||||||
|
|
||||||
|
// linuxProcMeminfoAvailableWant is MemAvailable (9876000 kB) converted to
|
||||||
|
// whole megabytes, floored: 9876000 / 1024 = 9644.53125 -> 9644.
|
||||||
|
var linuxProcMeminfoAvailableWant = 9876000 / 1024
|
||||||
|
|
||||||
|
// linuxProcMeminfoAvailableTest: interpret /proc/meminfo's MemAvailable
|
||||||
|
// field (not MemFree) as the memory actually available for new processes.
|
||||||
|
//
|
||||||
|
// ground truth: MemFree undercounts usable memory because it excludes
|
||||||
|
// reclaimable page cache and buffers; MemAvailable is the kernel's own
|
||||||
|
// estimate of memory available for new allocations without swapping,
|
||||||
|
// already accounting for reclaimable cache. /proc/meminfo's "kB" is always
|
||||||
|
// 1024-byte kibibytes, so converting to MB divides by 1024, not 1000.
|
||||||
|
func linuxProcMeminfoAvailableTest() testkit.Test {
|
||||||
|
prompt := `Here is a container host's /proc/meminfo:
|
||||||
|
|
||||||
|
` + linuxProcMeminfo + `
|
||||||
|
|
||||||
|
How much memory, in whole megabytes (rounded down), is actually available
|
||||||
|
for new processes to allocate without triggering swap? Use the correct
|
||||||
|
field for this - not MemFree, which excludes reclaimable cache and
|
||||||
|
buffers. Note that /proc/meminfo's "kB" unit is always 1024-byte kibibytes,
|
||||||
|
so convert by dividing by 1024, not 1000. Respond with only the number.`
|
||||||
|
|
||||||
|
return testkit.Test{
|
||||||
|
ID: "linux-proc-meminfo-available",
|
||||||
|
Category: "operations",
|
||||||
|
Subcategory: "linux",
|
||||||
|
Description: "Convert /proc/meminfo's MemAvailable field (not MemFree) to whole megabytes.",
|
||||||
|
Prompt: prompt,
|
||||||
|
Eval: eval.Numeric(eval.ExtractLastNumber[int], linuxProcMeminfoAvailableWant, 1),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -126,3 +126,237 @@ WantedBy=graphical.target`
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestLinuxJournalctlFilterTest_Eval(t *testing.T) {
|
||||||
|
tc := linuxJournalctlFilterTest()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
response string
|
||||||
|
want float64
|
||||||
|
}{
|
||||||
|
{"quoted negative window", `journalctl -u nginx.service --since "-2 hours"`, 1},
|
||||||
|
{"english phrasing window", `journalctl -u nginx.service --since "2 hours ago"`, 1},
|
||||||
|
{"compact -2h form, no quotes", `Run: journalctl -u nginx.service --since -2h`, 1},
|
||||||
|
{"wrong unit", `journalctl -u apache.service --since "-2 hours"`, 0},
|
||||||
|
{"missing unit filter entirely", `journalctl --since "-2 hours"`, 0},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := tc.Eval.Evaluate(context.Background(), tt.response)
|
||||||
|
if got.Value != tt.want {
|
||||||
|
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLinuxCgroupOOMDiagnosisTest_Eval(t *testing.T) {
|
||||||
|
tc := linuxCgroupOOMDiagnosisTest()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
response string
|
||||||
|
want float64
|
||||||
|
}{
|
||||||
|
{"correct object", `{"oom_killed": true, "oom_kill_count": 3}`, 1},
|
||||||
|
{"prose then JSON", "Yes, it was OOM-killed.\n" + `{"oom_killed": true, "oom_kill_count": 3}`, 1},
|
||||||
|
{"fenced JSON", "```json\n" + `{"oom_killed": true, "oom_kill_count": 3}` + "\n```", 1},
|
||||||
|
{"confuses oom counter with oom_kill counter", `{"oom_killed": true, "oom_kill_count": 47}`, 0.5},
|
||||||
|
{"wrongly claims no kill happened", `{"oom_killed": false, "oom_kill_count": 0}`, 0},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := tc.Eval.Evaluate(context.Background(), tt.response)
|
||||||
|
if got.Value != tt.want {
|
||||||
|
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLinuxNftablesDNATTest_Eval(t *testing.T) {
|
||||||
|
tc := linuxNftablesDNATTest()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
response string
|
||||||
|
want float64
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "iptables form",
|
||||||
|
response: `iptables -t nat -A PREROUTING -i eth0 -p tcp --dport 8080 -j DNAT --to-destination 10.0.5.20:80`,
|
||||||
|
want: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "iptables form with flags reordered around the match",
|
||||||
|
response: `sudo iptables -t nat -A PREROUTING -p tcp -i eth0 --dport 8080 -j DNAT --to-destination 10.0.5.20:80`,
|
||||||
|
want: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "nft form",
|
||||||
|
response: `nft add rule ip nat prerouting tcp dport 8080 dnat to 10.0.5.20:80`,
|
||||||
|
want: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wrong destination IP",
|
||||||
|
response: `iptables -t nat -A PREROUTING -p tcp --dport 8080 -j DNAT --to-destination 10.0.5.21:80`,
|
||||||
|
want: 0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wrong chain (OUTPUT instead of PREROUTING)",
|
||||||
|
response: `iptables -t nat -A OUTPUT -p tcp --dport 8080 -j DNAT --to-destination 10.0.5.20:80`,
|
||||||
|
want: 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := tc.Eval.Evaluate(context.Background(), tt.response)
|
||||||
|
if got.Value != tt.want {
|
||||||
|
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLinuxLVMExtendOrderTest_Eval(t *testing.T) {
|
||||||
|
tc := linuxLVMExtendOrderTest()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
response string
|
||||||
|
want float64
|
||||||
|
}{
|
||||||
|
{"correct order", `["pvcreate","vgextend","lvextend","resize2fs"]`, 1},
|
||||||
|
{"correct order, prose wrapped", "Steps:\n" + `["pvcreate","vgextend","lvextend","resize2fs"]`, 1},
|
||||||
|
{"correct order, fenced JSON", "```json\n" + `["pvcreate","vgextend","lvextend","resize2fs"]` + "\n```", 1},
|
||||||
|
{"wrong order (lvextend before vgextend)", `["pvcreate","lvextend","vgextend","resize2fs"]`, 0},
|
||||||
|
{"includes destructive mkfs instead of resize2fs", `["pvcreate","vgextend","lvextend","mkfs"]`, 0},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := tc.Eval.Evaluate(context.Background(), tt.response)
|
||||||
|
if got.Value != tt.want {
|
||||||
|
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLinuxSSHPortForwardTest_Eval(t *testing.T) {
|
||||||
|
tc := linuxSSHPortForwardTest()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
response string
|
||||||
|
want float64
|
||||||
|
}{
|
||||||
|
{"loopback IP form", `ssh -L 15432:127.0.0.1:5432 [email protected]`, 1},
|
||||||
|
{"localhost form", `ssh -L 15432:localhost:5432 [email protected]`, 1},
|
||||||
|
{"extra flags before -L", `ssh -N -L 15432:127.0.0.1:5432 [email protected]`, 1},
|
||||||
|
{"wrong local port", `ssh -L 5432:127.0.0.1:5432 [email protected]`, 0},
|
||||||
|
{"wrong host", `ssh -L 15432:127.0.0.1:5432 [email protected]`, 0},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := tc.Eval.Evaluate(context.Background(), tt.response)
|
||||||
|
if got.Value != tt.want {
|
||||||
|
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLinuxCronExpressionTest_Eval(t *testing.T) {
|
||||||
|
tc := linuxCronExpressionTest()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
response string
|
||||||
|
want float64
|
||||||
|
}{
|
||||||
|
{"canonical single spacing", "30 3 * * 1", 1},
|
||||||
|
{"extra spaces between fields", "30 3 * * 1", 1},
|
||||||
|
{"tab-separated fields", "30\t3\t*\t*\t1", 1},
|
||||||
|
{"wrong day of week", "30 3 * * 2", 0},
|
||||||
|
{"wrong time", "45 3 * * 1", 0},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := tc.Eval.Evaluate(context.Background(), tt.response)
|
||||||
|
if got.Value != tt.want {
|
||||||
|
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLinuxSystemdTimerTest_Eval(t *testing.T) {
|
||||||
|
tc := linuxSystemdTimerTest()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
response string
|
||||||
|
want float64
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "full correct answer",
|
||||||
|
response: `Create backup.service and backup.timer. In backup.timer's
|
||||||
|
[Timer] section: OnCalendar=*-*-* 02:00:00. In [Install]:
|
||||||
|
WantedBy=timers.target.`,
|
||||||
|
want: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "reordered phrasing, service named first then timer",
|
||||||
|
response: `You need a .service unit for the work and a .timer unit for the schedule. Set OnCalendar=02:00 in the timer, and WantedBy=timers.target under [Install].`,
|
||||||
|
want: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "minimal but complete",
|
||||||
|
response: `backup.service + backup.timer, OnCalendar=*-*-* 02:00:00, WantedBy=timers.target`,
|
||||||
|
want: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "missing OnCalendar key entirely",
|
||||||
|
response: `Use backup.service and backup.timer, with WantedBy=timers.target in [Install].`,
|
||||||
|
want: 2.0 / 3.0,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wrong install target and no OnCalendar (cron-style thinking)",
|
||||||
|
response: `Just add a backup.service and backup.timer, and enable it with WantedBy=multi-user.target.`,
|
||||||
|
want: 1.0 / 3.0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := tc.Eval.Evaluate(context.Background(), tt.response)
|
||||||
|
if got.Value != tt.want {
|
||||||
|
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestLinuxProcMeminfoAvailableTest_Eval(t *testing.T) {
|
||||||
|
tc := linuxProcMeminfoAvailableTest()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
response string
|
||||||
|
want float64
|
||||||
|
}{
|
||||||
|
{"exact number", "9644", 1},
|
||||||
|
{"prose wrapped", "The available memory is 9644 MB.", 1},
|
||||||
|
{"with unit suffix", "9644 MB", 1},
|
||||||
|
{"divided by 1000 instead of 1024", "9876", 0},
|
||||||
|
{"used MemFree instead of MemAvailable", "1000", 0},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := tc.Eval.Evaluate(context.Background(), tt.response)
|
||||||
|
if got.Value != tt.want {
|
||||||
|
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -8,6 +8,14 @@ import (
|
|||||||
func registerMacOSTests(r *testkit.Registry) {
|
func registerMacOSTests(r *testkit.Registry) {
|
||||||
r.Register(macosTimeoutPortabilityTest())
|
r.Register(macosTimeoutPortabilityTest())
|
||||||
r.Register(macosLaunchdCronTest())
|
r.Register(macosLaunchdCronTest())
|
||||||
|
r.Register(macosSedInplaceTest())
|
||||||
|
r.Register(macosLaunchctlBootstrapTest())
|
||||||
|
r.Register(macosPlutilPlistReadTest())
|
||||||
|
r.Register(macosMdfindSpotlightTest())
|
||||||
|
r.Register(macosAPFSSnapshotTest())
|
||||||
|
r.Register(macosSSHIdentityAgentTest())
|
||||||
|
r.Register(macosCaffeinateTest())
|
||||||
|
r.Register(macosStatBSDGNUTest())
|
||||||
}
|
}
|
||||||
|
|
||||||
// macosTimeoutPortabilityTest: explain why `timeout` is missing on stock
|
// macosTimeoutPortabilityTest: explain why `timeout` is missing on stock
|
||||||
@@ -82,3 +90,260 @@ file belongs in for a per-machine (not per-user) job.`
|
|||||||
Eval: evaluator,
|
Eval: evaluator,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// macosSedPattern requires the BSD-form "sed -i" with an empty
|
||||||
|
// single-quoted or double-quoted extension argument, invoking a foo->bar
|
||||||
|
// substitution on file.txt, on a single line (no DOTALL) so a scattered,
|
||||||
|
// multi-paragraph mention of the same tokens does not count as a real
|
||||||
|
// one-line command.
|
||||||
|
//
|
||||||
|
// ground truth: BSD sed's -i flag takes a mandatory in-place-suffix
|
||||||
|
// argument (used for backup files); passing an empty single-quoted or
|
||||||
|
// double-quoted string means "no backup file". GNU sed's -i takes an
|
||||||
|
// OPTIONAL argument, so
|
||||||
|
// "sed -i 's/foo/bar/' file.txt" (no argument to -i) works on Linux but
|
||||||
|
// errors on macOS, where it is parsed as "-i 's/foo/bar/'" (using the sed
|
||||||
|
// script itself as the backup suffix) followed by a missing script operand.
|
||||||
|
const macosSedPattern = `(?i)\bsed\b[^\n]*-i\s*(''|"")[^\n]*s/foo/bar/g?[^\n]*file\.txt`
|
||||||
|
|
||||||
|
// macosSedInplaceTest: give the portable BSD sed one-liner for in-place
|
||||||
|
// substitution with no backup file, contrasted with the GNU-only form that
|
||||||
|
// fails on stock macOS.
|
||||||
|
func macosSedInplaceTest() testkit.Test {
|
||||||
|
prompt := `On a stock macOS machine (BSD sed, not GNU sed), this command:
|
||||||
|
|
||||||
|
` + "```sh" + `
|
||||||
|
sed -i 's/foo/bar/' file.txt
|
||||||
|
` + "```" + `
|
||||||
|
|
||||||
|
fails with an error about a missing extension or a "no such file"
|
||||||
|
complaint. Give the corrected one-line BSD sed command that replaces "foo"
|
||||||
|
with "bar" in file.txt, edits the file in place, and creates NO backup
|
||||||
|
file.`
|
||||||
|
|
||||||
|
return testkit.Test{
|
||||||
|
ID: "macos-sed-inplace",
|
||||||
|
Category: "operations",
|
||||||
|
Subcategory: "macos",
|
||||||
|
Description: "Give the BSD sed -i '' in-place substitution syntax that works on stock macOS with no backup file.",
|
||||||
|
Prompt: prompt,
|
||||||
|
Eval: eval.Regex(macosSedPattern),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// macosLaunchctlBootstrapTest: identify launchctl bootstrap with an
|
||||||
|
// explicit system domain target as the modern replacement for the
|
||||||
|
// deprecated launchctl load, for loading a LaunchDaemon.
|
||||||
|
func macosLaunchctlBootstrapTest() testkit.Test {
|
||||||
|
prompt := `A new LaunchDaemon plist has just been written to
|
||||||
|
/Library/LaunchDaemons/com.example.myjob.plist on a current macOS release.
|
||||||
|
|
||||||
|
On modern macOS (10.11 El Capitan and later), which launchctl subcommand
|
||||||
|
should you use to load and start this LaunchDaemon, and which domain
|
||||||
|
target must you pass it (this is a system-wide daemon, not a per-user
|
||||||
|
agent)? Also name the older launchctl subcommand for loading jobs that
|
||||||
|
Apple deprecated in favor of this one, and briefly say why (it lacked
|
||||||
|
explicit domain targeting).`
|
||||||
|
|
||||||
|
// ground truth: Apple deprecated the session-based "launchctl load" /
|
||||||
|
// "launchctl unload" pair starting in OS X 10.11, replacing them with
|
||||||
|
// "launchctl bootstrap" / "launchctl bootout", which require an
|
||||||
|
// explicit domain target (system/, gui/<uid>, user/<uid>). A
|
||||||
|
// system-wide LaunchDaemon uses the "system" domain, giving
|
||||||
|
// "sudo launchctl bootstrap system /Library/LaunchDaemons/....plist".
|
||||||
|
// Component 3 deliberately requires an explicit deprecation cue rather
|
||||||
|
// than a bare "launchctl load" mention: a response that just uses
|
||||||
|
// launchctl load (as its own recommended command, not something to
|
||||||
|
// avoid) must not get credit for correctly flagging it as deprecated.
|
||||||
|
evaluator := eval.Mean(
|
||||||
|
eval.ContainsAny("bootstrap"),
|
||||||
|
eval.ContainsAny("bootstrap system", "system domain", "domain system", "target system", "system target"),
|
||||||
|
eval.ContainsAny("deprecated", "avoid launchctl load", "no longer recommended", "outdated"),
|
||||||
|
)
|
||||||
|
|
||||||
|
return testkit.Test{
|
||||||
|
ID: "macos-launchctl-bootstrap",
|
||||||
|
Category: "operations",
|
||||||
|
Subcategory: "macos",
|
||||||
|
Description: "Identify launchctl bootstrap+system domain as the modern replacement for the deprecated launchctl load.",
|
||||||
|
Prompt: prompt,
|
||||||
|
Eval: evaluator,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// macosPlutilPattern requires the built-in plutil -p pretty-printer
|
||||||
|
// invoked directly on the plist path, on one line.
|
||||||
|
const macosPlutilPattern = `(?i)\bplutil\b[^\n]*-p[^\n]*/Library/Preferences/com\.example\.app\.plist`
|
||||||
|
|
||||||
|
// macosPlutilPlistReadTest: pretty-print a plist file's contents using a
|
||||||
|
// tool that ships with macOS (no Homebrew installs).
|
||||||
|
func macosPlutilPlistReadTest() testkit.Test {
|
||||||
|
prompt := `You need to inspect the raw contents of this plist file on a
|
||||||
|
stock macOS machine, with no Homebrew packages installed:
|
||||||
|
|
||||||
|
/Library/Preferences/com.example.app.plist
|
||||||
|
|
||||||
|
Give a single command, using only a tool that ships with macOS by default,
|
||||||
|
that prints the plist's contents to your terminal in a readable
|
||||||
|
(non-binary) form.`
|
||||||
|
|
||||||
|
// ground truth: plutil ships with macOS and its -p flag pretty-prints a
|
||||||
|
// plist (binary or XML) to stdout in a human-readable form, taking the
|
||||||
|
// file path directly - no domain-name lookup or Homebrew install
|
||||||
|
// needed, unlike "defaults read <domain>".
|
||||||
|
return testkit.Test{
|
||||||
|
ID: "macos-plutil-plist-read",
|
||||||
|
Category: "operations",
|
||||||
|
Subcategory: "macos",
|
||||||
|
Description: "Pretty-print a plist file's contents using the built-in plutil -p command.",
|
||||||
|
Prompt: prompt,
|
||||||
|
Eval: eval.Regex(macosPlutilPattern),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// macosMdfindSpotlightTest: use mdfind (Spotlight's metadata index) instead
|
||||||
|
// of a full filesystem walk to find files by name.
|
||||||
|
func macosMdfindSpotlightTest() testkit.Test {
|
||||||
|
prompt := `On macOS, give a single command that searches Spotlight's
|
||||||
|
existing metadata index (not a full filesystem walk with "find") for files
|
||||||
|
whose name contains "invoice", scoped only to your home directory (~).`
|
||||||
|
|
||||||
|
// ground truth: mdfind queries the already-built Spotlight index rather
|
||||||
|
// than walking the filesystem; -onlyin scopes the search to a
|
||||||
|
// directory, and either -name or a plain query string matches on file
|
||||||
|
// name.
|
||||||
|
evaluator := eval.Mean(
|
||||||
|
eval.ContainsAll("mdfind"),
|
||||||
|
eval.ContainsAny("-onlyin", "-name"),
|
||||||
|
eval.ContainsAny("invoice"),
|
||||||
|
)
|
||||||
|
|
||||||
|
return testkit.Test{
|
||||||
|
ID: "macos-mdfind-spotlight",
|
||||||
|
Category: "operations",
|
||||||
|
Subcategory: "macos",
|
||||||
|
Description: "Use mdfind against Spotlight's index, scoped to a directory, instead of a full filesystem walk.",
|
||||||
|
Prompt: prompt,
|
||||||
|
Eval: evaluator,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// macosAPFSSnapshotTest: create and list local APFS snapshots via tmutil,
|
||||||
|
// which works even with no external Time Machine disk attached.
|
||||||
|
func macosAPFSSnapshotTest() testkit.Test {
|
||||||
|
prompt := `Before a risky system change, you want to create a local APFS
|
||||||
|
snapshot of the root volume as a quick rollback point - with no external
|
||||||
|
Time Machine disk attached (APFS local snapshots do not need one). Give
|
||||||
|
the command to create a local snapshot now, and the separate command to
|
||||||
|
list the local snapshots that currently exist.`
|
||||||
|
|
||||||
|
// ground truth: tmutil manages local APFS snapshots independently of
|
||||||
|
// having a Time Machine backup destination attached. "tmutil snapshot"
|
||||||
|
// creates one immediately; "tmutil listlocalsnapshots /" lists the
|
||||||
|
// local snapshots that exist for the root volume.
|
||||||
|
evaluator := eval.Mean(
|
||||||
|
eval.ContainsAny("tmutil snapshot"),
|
||||||
|
eval.ContainsAny("tmutil listlocalsnapshots", "listlocalsnapshots"),
|
||||||
|
)
|
||||||
|
|
||||||
|
return testkit.Test{
|
||||||
|
ID: "macos-apfs-snapshot",
|
||||||
|
Category: "operations",
|
||||||
|
Subcategory: "macos",
|
||||||
|
Description: "Create and list local APFS snapshots via tmutil with no external Time Machine disk attached.",
|
||||||
|
Prompt: prompt,
|
||||||
|
Eval: evaluator,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// macosSSHIdentityAgentTest: point ssh at 1Password's SSH agent socket via
|
||||||
|
// the IdentityAgent ssh_config directive.
|
||||||
|
func macosSSHIdentityAgentTest() testkit.Test {
|
||||||
|
prompt := `You use the 1Password app as your SSH agent instead of the
|
||||||
|
default ssh-agent, and your shell environment does NOT set SSH_AUTH_SOCK.
|
||||||
|
1Password's agent socket lives at:
|
||||||
|
|
||||||
|
~/Library/Group Containers/2BUA8C4S2C.com.1password/t/agent.sock
|
||||||
|
|
||||||
|
Give the single ssh_config line to add (to ~/.ssh/config, either globally
|
||||||
|
or under a Host block) that points ssh at this socket, so ssh and git-over-
|
||||||
|
ssh commands can find your keys through 1Password.`
|
||||||
|
|
||||||
|
// ground truth: ssh_config's IdentityAgent directive overrides which
|
||||||
|
// agent socket ssh (and anything using libssh's config, like git)
|
||||||
|
// connects to; 1Password's own setup docs specify this exact directive
|
||||||
|
// and socket path for its SSH agent integration.
|
||||||
|
evaluator := eval.Mean(
|
||||||
|
eval.ContainsAny("IdentityAgent"),
|
||||||
|
eval.ContainsAny("1password/t/agent.sock", "agent.sock"),
|
||||||
|
)
|
||||||
|
|
||||||
|
return testkit.Test{
|
||||||
|
ID: "macos-ssh-identity-agent",
|
||||||
|
Category: "operations",
|
||||||
|
Subcategory: "macos",
|
||||||
|
Description: "Point ssh at 1Password's SSH agent socket via the IdentityAgent ssh_config directive.",
|
||||||
|
Prompt: prompt,
|
||||||
|
Eval: evaluator,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// macosCaffeinateTest: use caffeinate, built into macOS, to keep the
|
||||||
|
// machine awake only for the duration of a given command.
|
||||||
|
func macosCaffeinateTest() testkit.Test {
|
||||||
|
prompt := `A long-running data migration script, ./migrate.sh, needs to
|
||||||
|
run on a MacBook without the machine going to sleep partway through - and
|
||||||
|
you want sleep prevention to end automatically the moment the script
|
||||||
|
finishes, with no separate cleanup step. Give a single command, using a
|
||||||
|
tool built into macOS, that does this.`
|
||||||
|
|
||||||
|
// ground truth: caffeinate, bundled with macOS, takes a power-management
|
||||||
|
// assertion for as long as a given utility runs when you pass that
|
||||||
|
// utility as its argument, and releases the assertion automatically
|
||||||
|
// when the utility exits - no manual cleanup needed.
|
||||||
|
evaluator := eval.Mean(
|
||||||
|
eval.ContainsAll("caffeinate"),
|
||||||
|
eval.ContainsAny("migrate.sh"),
|
||||||
|
)
|
||||||
|
|
||||||
|
return testkit.Test{
|
||||||
|
ID: "macos-caffeinate",
|
||||||
|
Category: "operations",
|
||||||
|
Subcategory: "macos",
|
||||||
|
Description: "Use caffeinate to prevent sleep only for the duration of a given script.",
|
||||||
|
Prompt: prompt,
|
||||||
|
Eval: evaluator,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// macosStatPattern requires the BSD stat -f format-string form printing
|
||||||
|
// only the byte size (%z) of file.txt.
|
||||||
|
const macosStatPattern = `(?i)\bstat\b[^\n]*-f\s*"?%z"?[^\n]*file\.txt`
|
||||||
|
|
||||||
|
// macosStatBSDGNUTest: give the BSD stat command (macOS's built-in stat)
|
||||||
|
// for a file's size in bytes, and name the differing GNU stat flag.
|
||||||
|
func macosStatBSDGNUTest() testkit.Test {
|
||||||
|
prompt := `On macOS (BSD stat, not GNU stat), give a single command that
|
||||||
|
prints ONLY the size of file.txt in bytes, nothing else. Then name the
|
||||||
|
single-letter flag GNU stat (as found on Linux) uses in place of macOS's
|
||||||
|
-f for its own format-string argument.`
|
||||||
|
|
||||||
|
// ground truth: BSD stat takes its format string via -f, and %z is the
|
||||||
|
// byte-size format specifier, giving "stat -f%z file.txt". GNU stat
|
||||||
|
// instead uses -c for its format string (with %s as the byte-size
|
||||||
|
// specifier) - the flag letter itself, not just the format string
|
||||||
|
// syntax, differs between the two implementations.
|
||||||
|
evaluator := eval.Mean(
|
||||||
|
eval.Regex(macosStatPattern),
|
||||||
|
eval.ContainsAny("-c"),
|
||||||
|
)
|
||||||
|
|
||||||
|
return testkit.Test{
|
||||||
|
ID: "macos-stat-bsd-gnu",
|
||||||
|
Category: "operations",
|
||||||
|
Subcategory: "macos",
|
||||||
|
Description: "Give the BSD stat -f%z byte-size command and name the differing GNU stat flag (-c).",
|
||||||
|
Prompt: prompt,
|
||||||
|
Eval: evaluator,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -78,3 +78,227 @@ StartInterval set to 300.`
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestMacosSedInplaceTest_Eval(t *testing.T) {
|
||||||
|
tc := macosSedInplaceTest()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
response string
|
||||||
|
want float64
|
||||||
|
}{
|
||||||
|
{"single-quote empty extension", `sed -i '' 's/foo/bar/' file.txt`, 1},
|
||||||
|
{"double-quote empty extension with -e flag", `On macOS: sed -i "" -e 's/foo/bar/' file.txt`, 1},
|
||||||
|
{"global replace flag still matches", `sed -i '' 's/foo/bar/g' file.txt`, 1},
|
||||||
|
{"GNU-style with no extension argument is wrong on macOS", `sed -i 's/foo/bar/' file.txt`, 0},
|
||||||
|
{"backup extension creates a file, contradicting the requirement", `sed -i.bak 's/foo/bar/' file.txt`, 0},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := tc.Eval.Evaluate(context.Background(), tt.response)
|
||||||
|
if got.Value != tt.want {
|
||||||
|
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMacosLaunchctlBootstrapTest_Eval(t *testing.T) {
|
||||||
|
tc := macosLaunchctlBootstrapTest()
|
||||||
|
|
||||||
|
good := `Use "sudo launchctl bootstrap system /Library/LaunchDaemons/com.example.myjob.plist".
|
||||||
|
The older "launchctl load" command is deprecated because it has no explicit
|
||||||
|
domain target.`
|
||||||
|
goodReordered := `The modern subcommand is bootstrap, targeting the system
|
||||||
|
domain: sudo launchctl bootstrap system /Library/LaunchDaemons/com.example.myjob.plist.
|
||||||
|
launchctl load is deprecated.`
|
||||||
|
goodPhrasing := `Load it with launchctl bootstrap, using the system domain
|
||||||
|
target since this is a system-wide daemon. Avoid launchctl load, which
|
||||||
|
Apple deprecated for lacking an explicit domain target.`
|
||||||
|
badWrongSubcommand := `Just run "sudo launchctl load /Library/LaunchDaemons/com.example.myjob.plist".`
|
||||||
|
badMissingDomain := `Use launchctl bootstrap to load the daemon.`
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
response string
|
||||||
|
want float64
|
||||||
|
}{
|
||||||
|
{"good answer full credit", good, 1},
|
||||||
|
{"good answer, reordered phrasing", goodReordered, 1},
|
||||||
|
{"good answer, alternate phrasing", goodPhrasing, 1},
|
||||||
|
{"wrong subcommand (deprecated load only) scores 0", badWrongSubcommand, 0},
|
||||||
|
{"missing domain target and deprecation note loses two thirds", badMissingDomain, 1.0 / 3.0},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := tc.Eval.Evaluate(context.Background(), tt.response)
|
||||||
|
if got.Value != tt.want {
|
||||||
|
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMacosPlutilPlistReadTest_Eval(t *testing.T) {
|
||||||
|
tc := macosPlutilPlistReadTest()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
response string
|
||||||
|
want float64
|
||||||
|
}{
|
||||||
|
{"correct plutil -p", `plutil -p /Library/Preferences/com.example.app.plist`, 1},
|
||||||
|
{"prose wrapped", `Run: plutil -p /Library/Preferences/com.example.app.plist to pretty-print it.`, 1},
|
||||||
|
{"quoted path", `plutil -p "/Library/Preferences/com.example.app.plist"`, 1},
|
||||||
|
{"wrong tool entirely (cat prints binary garbage)", `cat /Library/Preferences/com.example.app.plist`, 0},
|
||||||
|
{"plutil without -p just validates, does not print", `plutil /Library/Preferences/com.example.app.plist`, 0},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := tc.Eval.Evaluate(context.Background(), tt.response)
|
||||||
|
if got.Value != tt.want {
|
||||||
|
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMacosMdfindSpotlightTest_Eval(t *testing.T) {
|
||||||
|
tc := macosMdfindSpotlightTest()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
response string
|
||||||
|
want float64
|
||||||
|
}{
|
||||||
|
{"onlyin with query", `mdfind -onlyin ~ "invoice"`, 1},
|
||||||
|
{"name flag", `mdfind -onlyin ~ -name invoice`, 1},
|
||||||
|
{"prose wrapped", `Run mdfind -onlyin ~ invoice to search the Spotlight index.`, 1},
|
||||||
|
{"uses find instead of mdfind, walks the filesystem", `find ~ -iname "*invoice*"`, 1.0 / 3.0},
|
||||||
|
{"mdfind used but searching for the wrong term entirely", `mdfind -onlyin ~ "receipt"`, 2.0 / 3.0},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := tc.Eval.Evaluate(context.Background(), tt.response)
|
||||||
|
if got.Value != tt.want {
|
||||||
|
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMacosAPFSSnapshotTest_Eval(t *testing.T) {
|
||||||
|
tc := macosAPFSSnapshotTest()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
response string
|
||||||
|
want float64
|
||||||
|
}{
|
||||||
|
{"both commands", "Create: tmutil snapshot\nList: tmutil listlocalsnapshots /", 1},
|
||||||
|
{"prose wrapped", "Run `tmutil snapshot` to create one, then `tmutil listlocalsnapshots /` to list them.", 1},
|
||||||
|
{"listlocalsnapshots without explicit tmutil prefix restated", "First tmutil snapshot, then check with listlocalsnapshots /.", 1},
|
||||||
|
{"only creates, never lists", "Just run tmutil snapshot.", 0.5},
|
||||||
|
{"wrong tool (diskutil) mentioned instead of tmutil", "Use diskutil apfs snapshot / to create one.", 0},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := tc.Eval.Evaluate(context.Background(), tt.response)
|
||||||
|
if got.Value != tt.want {
|
||||||
|
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMacosSSHIdentityAgentTest_Eval(t *testing.T) {
|
||||||
|
tc := macosSSHIdentityAgentTest()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
response string
|
||||||
|
want float64
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
name: "correct directive and socket",
|
||||||
|
response: `IdentityAgent "~/Library/Group Containers/2BUA8C4S2C.com.1password/t/agent.sock"`,
|
||||||
|
want: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "inside a Host block, prose wrapped",
|
||||||
|
response: "Add this under your Host block:\nHost *\n IdentityAgent ~/Library/Group Containers/2BUA8C4S2C.com.1password/t/agent.sock",
|
||||||
|
want: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "shortened socket mention still credited",
|
||||||
|
response: "Set IdentityAgent to point at 1Password's agent.sock in its Group Containers path.",
|
||||||
|
want: 1,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "sets SSH_AUTH_SOCK env var instead of ssh_config directive",
|
||||||
|
response: `export SSH_AUTH_SOCK=~/Library/Group Containers/2BUA8C4S2C.com.1password/t/agent.sock`,
|
||||||
|
want: 0.5,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "wrong directive entirely",
|
||||||
|
response: `Add IdentityFile ~/.ssh/id_ed25519 to your config.`,
|
||||||
|
want: 0,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := tc.Eval.Evaluate(context.Background(), tt.response)
|
||||||
|
if got.Value != tt.want {
|
||||||
|
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMacosCaffeinateTest_Eval(t *testing.T) {
|
||||||
|
tc := macosCaffeinateTest()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
response string
|
||||||
|
want float64
|
||||||
|
}{
|
||||||
|
{"bare invocation", `caffeinate ./migrate.sh`, 1},
|
||||||
|
{"with system-sleep flag", `caffeinate -s ./migrate.sh`, 1},
|
||||||
|
{"prose wrapped", "Run `caffeinate -i ./migrate.sh` and it stops holding the assertion once migrate.sh exits.", 1},
|
||||||
|
{"wrong script named, not the target", `caffeinate ./other-script.sh`, 0.5},
|
||||||
|
{"unrelated tool, does not auto-release on exit", `Just run pmset noidle before starting your script.`, 0},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := tc.Eval.Evaluate(context.Background(), tt.response)
|
||||||
|
if got.Value != tt.want {
|
||||||
|
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMacosStatBSDGNUTest_Eval(t *testing.T) {
|
||||||
|
tc := macosStatBSDGNUTest()
|
||||||
|
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
response string
|
||||||
|
want float64
|
||||||
|
}{
|
||||||
|
{"correct BSD form and GNU flag", `stat -f%z file.txt` + "\nGNU stat uses -c instead.", 1},
|
||||||
|
{"quoted format string", `stat -f "%z" file.txt, and on Linux GNU stat uses -c.`, 1},
|
||||||
|
{"prose wrapped", "Run stat -f%z file.txt on macOS; the GNU equivalent flag is -c.", 1},
|
||||||
|
{"correct BSD command but never names the GNU flag", `stat -f%z file.txt`, 0.5},
|
||||||
|
{"GNU -c%s syntax used on macOS scores half: -c token matches but -f BSD form is missing", `stat -c%s file.txt`, 0.5},
|
||||||
|
}
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := tc.Eval.Evaluate(context.Background(), tt.response)
|
||||||
|
if got.Value != tt.want {
|
||||||
|
t.Errorf("Eval.Evaluate(%q) = %v, want %v (detail: %s)", tt.response, got.Value, tt.want, got.Detail)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user