Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions .agent/rules/testing.md
Original file line number Diff line number Diff line change
Expand Up @@ -154,7 +154,7 @@ GOOD:
```bash
trace $CLI bundle plan | contains.py "Plan: 0 to add, 0 to delete, 1 to update"
trace print_requests.py //api/2.0/apps
echo "$deployment_id:DEPLOYMENT_ID" >> ACC_REPLS
add_repl "$deployment_id" DEPLOYMENT_ID
```

BAD:
Expand All @@ -169,7 +169,7 @@ Available on `PATH` during test execution (from `acceptance/bin/`):
- `print_requests.py //path [^//exclude] [--get] [--sort] [--unique] [--oneline] [--keep]`: print recorded HTTP requests matching path filters. Requires `RecordRequests = true` in `test.toml`. Excludes GET by default (`--get` includes them); clears `out.requests.txt` afterwards (`--keep` retains it). `^` prefix excludes a path; multiple positive filters are OR'd together. `--sort` orders output deterministically (use when the request set is order-independent), `--unique` collapses consecutive duplicates (e.g. repeated polls), `--oneline` prints one request per line.
- `replace_ids.py [-t TARGET]`: read deployment state and add `[NAME_ID]` replacements for all resource IDs.
- `read_id.py [-t TARGET] NAME`: read ID of a single resource from state, print it, and add a `[NAME_ID]` replacement.
- `add_repl.py VALUE REPLACEMENT`: add a custom replacement (VALUE will be replaced with `[REPLACEMENT]` in output).
- `add_repl VALUE REPLACEMENT`: add a custom replacement (VALUE will be replaced with `[REPLACEMENT]` in output). Wraps `add_repl.py`, which appends a JSON line to `$ACC_REPLS` — the file holding every replacement applied to the output, read back by the harness and by `diff.py` / `sort_lines.py --repl`. Always go through the helper; do not write `$ACC_REPLS` from a script.
- `update_file.py FILENAME OLD NEW`: replace all occurrences of OLD with NEW in FILENAME. Errors if OLD is not found. Cannot be used on `output.txt`.
- `find.py REGEX [--expect N]`: find files matching regex in current directory. `--expect N` asserts an exact count.
- `diff.py DIR1 DIR2` or `diff.py FILE1 FILE2`: recursive diff with test replacements applied.
Expand Down
81 changes: 47 additions & 34 deletions acceptance/acceptance_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,6 @@ import (
"flag"
"fmt"
"io"
"io/fs"
"maps"
"math/rand/v2"
"net/http"
Expand Down Expand Up @@ -93,8 +92,6 @@ const (
CleanupScript = "script.cleanup"
PrepareScript = "script.prepare"
MaxFileSize = 1_000_000
// Filename to save replacements to (used by diff.py)
ReplsFile = "repls.json"
// Filename for materialized config (used as golden file)
MaterializedConfigFile = "out.test.toml"

Expand All @@ -103,11 +100,19 @@ const (
// The tests the don't set SERVERLESS variable or set to empty string will also be run.
EnvFilterVar = "ENVFILTER"

// File where scripts can output custom replacements
// export $job_id=100200300
// $ echo "$job_id:MY_JOB" >> ACC_REPLS # This will replace 100200300 with [MY_JOB] in the output
// TODO: this should be merged with repls.json functionality, currently these replacements are not parsed by diff.py
userReplacementsFilename = "ACC_REPLS"
// Env var with the path to the file holding all replacements applied to the output.
// It is kept outside of the test directory, otherwise "bundle deploy" uploads it.
//
// Every line is one replacement encoded as a JSON object: "Old" is a regular expression
// (written by the harness), "Literal" is a value to replace verbatim (appended by
// add_repl.py). The harness writes its own replacements first, then the scripts add theirs:
//
// $ job_id=100200300
// $ add_repl "$job_id" MY_JOB # replaces 100200300 with [MY_JOB] in the output
//
// The file is read back here (see loadScriptReplacements) and by the python helpers
// (see bin/repls.py).
ReplsEnvVar = "ACC_REPLS"
)

var ApplyCITimeoutMultipler = os.Getenv("GITHUB_WORKFLOW") != ""
Expand All @@ -125,11 +130,6 @@ var Scripts = map[string]bool{
PrepareScript: true,
}

var Ignored = map[string]bool{
ReplsFile: true,
userReplacementsFilename: true,
}

func TestAccept(t *testing.T) {
testAccept(t, InprocessMode, "")
}
Expand Down Expand Up @@ -888,6 +888,9 @@ func runTest(t *testing.T,
cmd.Env = append(cmd.Env, "UNIQUE_NAME="+uniqueName)
cmd.Env = append(cmd.Env, "TEST_TMP_DIR="+tmpDir)

replsPath := filepath.Join(t.TempDir(), ReplsEnvVar)
cmd.Env = append(cmd.Env, ReplsEnvVar+"="+replsPath)

// populate CLOUD_ENV_BASE
envBase := getCloudEnvBase(cloudEnv)
cmd.Env = append(cmd.Env, "CLOUD_ENV_BASE="+envBase)
Expand All @@ -898,10 +901,17 @@ func runTest(t *testing.T,
// User replacements:
repls.Repls = append(repls.Repls, config.Repls...)

// Save replacements to temp test directory so that it can be read by diff.py
replsJson, err := json.MarshalIndent(repls.Repls, "", " ")
require.NoError(t, err)
testutil.WriteFile(t, filepath.Join(tmpDir, ReplsFile), string(replsJson))
// Save replacements so that they can be read by the scripts (diff.py, sort_lines.py).
// One JSON object per line, because scripts append their own replacements to this file.
var replsLines strings.Builder
for _, repl := range repls.Repls {
line, err := json.Marshal(repl)
require.NoError(t, err)
replsLines.Write(line)
replsLines.WriteByte('\n')
}
testutil.WriteFile(t, replsPath, replsLines.String())
replsWritten := len(repls.Repls)

if coverDir != "" {
// Creating individual coverage directory for each test, because writing to the same one
Expand Down Expand Up @@ -1003,7 +1013,7 @@ func runTest(t *testing.T,
formatOutput(out, err)
require.NoError(t, out.Close())

loadUserReplacements(t, &repls, tmpDir)
loadScriptReplacements(t, &repls, replsPath, replsWritten)

printedRepls := false

Expand All @@ -1028,9 +1038,6 @@ func runTest(t *testing.T,
if _, ok := outputs[relPath]; ok {
continue
}
if _, ok := Ignored[relPath]; ok {
continue
}
if config.CompiledIgnoreObject.MatchesPath(relPath) && !strings.HasPrefix(relPath, "out") {
continue
}
Expand Down Expand Up @@ -1810,26 +1817,32 @@ func setupTerraform(t *testing.T, cwd, buildDir string, repls *testdiff.Replacem
repls.SetPath(terraformExecPath, "[TERRAFORM]")
}

func loadUserReplacements(t *testing.T, repls *testdiff.ReplacementsContext, tmpDir string) {
b, err := os.ReadFile(filepath.Join(tmpDir, userReplacementsFilename))
if errors.Is(err, fs.ErrNotExist) {
return
}
// loadScriptReplacements adds the replacements appended to replsPath by the scripts.
// The first offset lines were written by the harness itself and are already in repls.
func loadScriptReplacements(t *testing.T, repls *testdiff.ReplacementsContext, replsPath string, offset int) {
b, err := os.ReadFile(replsPath)
require.NoError(t, err)
lines := strings.SplitSeq(string(b), "\n")
for line := range lines {
lines := strings.Split(string(b), "\n")
for _, line := range lines[min(offset, len(lines)):] {
line = strings.TrimSpace(line)
if len(line) == 0 {
continue
}
items := strings.Split(line, ":")
if len(items) <= 1 {
t.Errorf("Error parsing %s: %#v", userReplacementsFilename, line)
// Scripts only add literal replacements; regular expressions come from the harness.
var entry struct {
Literal string
New string
Order int
}
if err := json.Unmarshal([]byte(line), &entry); err != nil {
t.Errorf("Error parsing %s: %#v: %s", ReplsEnvVar, line, err)
continue
}
if entry.Literal == "" || entry.New == "" {
t.Errorf("Incomplete entry in %s: %#v", ReplsEnvVar, line)
continue
}
repl := items[len(items)-1]
old := line[:len(line)-len(repl)-1]
repls.SetWithOrder(old, "["+repl+"]", -100)
repls.SetWithOrder(entry.Literal, entry.New, entry.Order)
}
}

Expand Down
20 changes: 11 additions & 9 deletions acceptance/bin/add_repl.py
Original file line number Diff line number Diff line change
Expand Up @@ -6,19 +6,20 @@
"""

import argparse
import json
import os
import sys
from pathlib import Path

ACC_REPLS = Path(os.environ["TEST_TMP_DIR"]) / "ACC_REPLS"
sys.path.insert(0, str(Path(__file__).parent))
from repls import USER_ORDER, read_entries

ACC_REPLS = Path(os.environ["ACC_REPLS"])


def get_repls():
result = {}
if ACC_REPLS.exists():
for line in ACC_REPLS.open():
value, repl = line.strip().rsplit(":", 1)
result[repl] = value
return result
# Only the literal entries are added here, so only those can collide.
return {item["New"] for item in read_entries() if item.get("Literal") is not None}


def add_repl(value, repl):
Expand All @@ -28,10 +29,11 @@ def add_repl(value, repl):
r = f"{repl}_{extra}"
else:
r = repl
if r in existing:
if f"[{r}]" in existing:
continue
with ACC_REPLS.open("a") as fobj:
fobj.write(f"{value}:{r}\n")
json.dump({"Literal": value, "New": f"[{r}]", "Order": USER_ORDER}, fobj)
fobj.write("\n")
break


Expand Down
25 changes: 5 additions & 20 deletions acceptance/bin/diff.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,33 +2,18 @@
"""This script implements "diff -r -U2 dir1 dir2" but applies replacements first"""

import difflib
import json
import os
import re
import sys
from pathlib import Path


def replaceAll(patterns, s):
for comp, new in patterns:
s = comp.sub(new, s)
return s
sys.path.insert(0, str(Path(__file__).parent))
from repls import compile_repls, replace_all


def main():
d1, d2 = sys.argv[1:]
d1, d2 = Path(d1), Path(d2)

repls_json = Path(os.environ["TEST_TMP_DIR"]) / "repls.json"
repls = json.loads(repls_json.read_text())

patterns = []
for r in repls:
try:
c = re.compile(r["Old"])
patterns.append((c, r["New"]))
except re.error as e:
print(f"Regex error for pattern {r}: {e}", file=sys.stderr)
patterns = compile_repls()

if d1.is_dir() and d2.is_dir():
files1 = [str(p.relative_to(d1)) for p in d1.rglob("*") if p.is_file() and not p.name.startswith("LOG")]
Expand All @@ -54,8 +39,8 @@ def main():


def diff_files(patterns, p1, p2):
a = replaceAll(patterns, p1.read_text()).splitlines(True)
b = replaceAll(patterns, p2.read_text()).splitlines(True)
a = replace_all(patterns, p1.read_text()).splitlines(True)
b = replace_all(patterns, p2.read_text()).splitlines(True)
if a != b:
p1_str = p1.as_posix()
p2_str = p2.as_posix()
Expand Down
2 changes: 1 addition & 1 deletion acceptance/bin/read_id.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
Print id of the resource from the state. Update ACC_REPLS for a given ID.

Example: read_id.py foo
Output job_id, e.g. "5555" and update ACC_REPLS with record "5555:FOO_ID"
Output job_id, e.g. "5555" and update ACC_REPLS to replace "5555" with [FOO_ID]

Usage: <group> <name> [attr...]
"""
Expand Down
72 changes: 72 additions & 0 deletions acceptance/bin/repls.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
#!/usr/bin/env python3
"""
Read the replacements applied to the test output from $ACC_REPLS.

Every line is one replacement encoded as a JSON object: "Old" is a regular expression
(written by the test harness), "Literal" is a value to replace verbatim (appended by
add_repl.py). "New" is the replacement, "Order" defines the order they are applied in.
"""

import json
import os
import re
import sys
from pathlib import Path

# Order of the replacements added by the scripts, so that they are applied before the ones
# from the harness: a job id must become [MY_JOB] rather than [NUMID].
USER_ORDER = -100


def read_entries():
"""Return the raw entries of $ACC_REPLS."""
result = []
for line in Path(os.environ["ACC_REPLS"]).read_text().splitlines():
line = line.strip()
if line:
result.append(json.loads(line))
return result


def read_repls():
"""Return (pattern, replacement) pairs in the order they must be applied."""
entries = []

for item in read_entries():
order = item.get("Order", 0)
new = item["New"]
literal = item.get("Literal")

if literal is None:
# "Distinct" is not honoured here; unlike the harness, we do not number the matches.
entries.append((order, item["Old"], new))
continue

# Set() in libs/testdiff also registers the JSON-encoded form of the value, so that
# values with quotes or backslashes are replaced inside JSON output as well.
encoded = json.dumps(literal, ensure_ascii=False)[1:-1]
if encoded != literal:
entries.append((order, re.escape(encoded), new))
entries.append((order, re.escape(literal), new))

# Stable sort: replacements with the same order are applied in the order they were added.
entries.sort(key=lambda entry: entry[0])

return [(old, new) for _, old, new in entries]


def compile_repls():
"""Same as read_repls(), with patterns compiled. Invalid patterns are reported and skipped."""
result = []
for old, new in read_repls():
try:
result.append((re.compile(old), new))
except re.error as e:
print(f"Regex error for pattern {old}: {e}", file=sys.stderr)
return result


def replace_all(patterns, s):
for comp, new in patterns:
s = comp.sub(new, s)
return s
24 changes: 6 additions & 18 deletions acceptance/bin/sort_lines.py
Original file line number Diff line number Diff line change
@@ -1,34 +1,22 @@
#!/usr/bin/env python3
"""
Helper to sort lines in text file. Similar to 'sort' but no dependence on locale or presence of 'sort' in PATH.
With --repl, applies TEST_TMP_DIR/repls.json replacements as sort key for stable output across different environments.
With --repl, applies the test replacements ($ACC_REPLS) as sort key for stable output across different environments.
"""

import json
import os
import re
import sys
from pathlib import Path

sys.path.insert(0, str(Path(__file__).parent))
from repls import compile_repls, replace_all

use_repl = "--repl" in sys.argv[1:]

lines = sys.stdin.readlines()

if use_repl:
repls = json.loads((Path(os.environ["TEST_TMP_DIR"]) / "repls.json").read_text())
patterns = []
for r in repls:
try:
patterns.append((re.compile(r["Old"]), r["New"]))
except re.error as e:
print(f"Regex error for pattern {r}: {e}", file=sys.stderr)

def sort_key(line):
for comp, new in patterns:
line = comp.sub(new, line)
return line

lines.sort(key=sort_key)
patterns = compile_repls()
lines.sort(key=lambda line: replace_all(patterns, line))
else:
lines.sort()

Expand Down
2 changes: 1 addition & 1 deletion acceptance/bundle/apps/git_source/script
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ trace $CLI bundle run my_app

title "Get app details and verify git_source configuration"
app_name=$(trace $CLI bundle summary --output json | jq -r '.resources.apps.my_app.name')
echo "$app_name:APP_NAME" >> ACC_REPLS
add_repl "$app_name" APP_NAME

trace $CLI apps get $app_name --output json | jq '{name, description, git_repository, git_source}'

Expand Down
1 change: 0 additions & 1 deletion acceptance/bundle/artifacts/whl_change_version/output.txt
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,6 @@ dist/my_test_code-0.1.0-py3-none-any.whl
"/api/2.0/workspace-files/import-file/Workspace/Users/[USERNAME]/.bundle/test-bundle/default/files/my_test_code/__main__.py"
"/api/2.0/workspace-files/import-file/Workspace/Users/[USERNAME]/.bundle/test-bundle/default/files/out.requests.txt"
"/api/2.0/workspace-files/import-file/Workspace/Users/[USERNAME]/.bundle/test-bundle/default/files/output.txt"
"/api/2.0/workspace-files/import-file/Workspace/Users/[USERNAME]/.bundle/test-bundle/default/files/repls.json"
"/api/2.0/workspace-files/import-file/Workspace/Users/[USERNAME]/.bundle/test-bundle/default/files/script"
"/api/2.0/workspace-files/import-file/Workspace/Users/[USERNAME]/.bundle/test-bundle/default/files/setup.py"
"/api/2.0/workspace-files/import-file/Workspace/Users/[USERNAME]/.bundle/test-bundle/default/files/test.toml"
Expand Down
Loading
Loading