diff --git a/LESSONS.md b/LESSONS.md index a88e245..b9915ea 100644 --- a/LESSONS.md +++ b/LESSONS.md @@ -468,3 +468,33 @@ response = json.loads(proc.stdout.readline()) - 却下した案: skill の改名・日本語化を新規の書き起こしとして扱い、レビューは新しい文面が読みやすく自然かだけを見る。 - 決め手: test-runner の skill を日本語化したコミットで、旧版にあった注意書き5件(`no_tests` はテストが通った証拠にならない/件数には親テストとサブテストの両方が入る/error・timeout・cancelled を成功扱いしない/自動リトライしない/テスト範囲はむやみに広げない)が消えていた。さらに「全件スキップは `no_tests`」という実装と逆の記述が入った。実装(`events.go`)では件数がすべて 0 のときだけ `no_tests` で、全件スキップは `passed` になる。README は正しかったので、食い違いは skill だけで起きていた。旧版と新版を並べて差分を取り、ステータスの定義をコードの分岐と突き合わせたことで見つかった。 - 覆す条件: skill の記述を実装や README から生成・検証する仕組み(CI での照合など)が入り、手作業の書き換えで意味が変わらなくなった場合。 + +## 2026-09-20: `.go` の判定はパターンの綴りではなく `go list` の解決結果で行う + +- 却下した案: パッケージパターン末尾の `.go` を禁止して `.go` ファイル一覧を弾く(test-runner 初版の実装)。 +- 決め手: `go list -e -json` の実測で、`pkg.go` という名前のディレクトリは import path `example.com/exp/pkg.go` として解決され、ファイル一覧は `command-line-arguments`(`Module` 欠落)として解決される。綴り判定は正当なパッケージを拒否するうえ、失敗時に自分が生成した `rerun`(`packages` に解決済み import path を入れる)を再受理できず round-trip が壊れる。解決済み import path なら `go test example.com/exp/pkg.go` はファイル一覧と解釈されず正常実行される(ファイル一覧モードは実在ファイルが条件)。混在指定・不存在パスも `Error` 付きレコードとして解決結果で弾ける。 +- 覆す条件: `go list` がファイル一覧を `command-line-arguments` 以外で表すようになった場合。 + +## 2026-09-20: 呼び出し側の不正な `-run` 式はツール実行前に入力エラーとして弾く + +- 却下した案: 不正な式をそのまま `go test` に渡す(初版)か、出力の `testing: invalid regexp` 文字列を検出して事後判定する。 +- 決め手: 実測で `-run '['` は全パッケージに package 単位の `fail` イベント+exit 1 を出し、失敗テスト0件のまま `status: failed`・MCP `isError: false` になる=入力エラーがプロジェクトのテスト失敗に化ける。固定 Go の `src/testing/match.go` の `splitRegexp` と同じ分割(`[]`・`()`・`\` を尊重)+空白→`_` 置換で各要素を `regexp.Compile` すると、実 `go test -run` と22ケースで完全一致した。`\` 分岐を外した版と実測比較したところ、差が出るのは呼び出し側が渡す `a\/b`・`a\|b`(エスケープされた区切り)で、分割が `a\` を作って `regexp.Compile` が落ち、go が受理する式を誤拒否する。`exactRun` の出力は `/` 区切りで連結した QuoteMeta 済み要素なので、分割が崩れて要素が結合しても必ずコンパイルでき、この分岐の有無に影響されない(当初「自分の rerun を誤拒否する」と推定したが実測で否定)。空白置換を写さないと `(?Px)` を誤って拒否する(go 側は `rewrite` で有効化する)。非印字文字のエスケープは正規表現の妥当性を変えないため写していない。 +- 覆す条件: `testing` 側の分割・`rewrite` 仕様が変わり一致が崩れた場合(固定 Go の `src/testing/match.go` を再 diff して判断する)。 + +## 2026-09-20: `testing.rewrite` は空白置換だけでなく非印字エスケープまで写す(直前の教訓を覆す) + +- 却下した案: 非印字文字のエスケープは正規表現の妥当性を変えないとして写さない(直前エントリの結論)。および、写す際に空白判定を `unicode.IsSpace` で代用する。 +- 決め手: 実 `go test -run` で双方向にズレを観測した。U+200B 単体は `invalid regexp for element 0 of -test.run ("​"): invalid escape sequence` で失敗する(=旧実装が受理して入力エラーがテスト失敗に化ける、この機能が防ぐはずだった当の症状)。逆に `\`+U+200B は `ok` で通る(=旧実装が誤拒否)。妥当性を変えないという前提が誤りだった。空白判定を代用しない理由は別。全ルーン走査(U+0000–U+10FFFF)で `testing.isSpace` と `unicode.IsSpace` の相違は0件、固定 1.26.8 と 1.27.1 の `isSpace` も同一だが、`testing` 側は「not the same as Unicode Z class」と明記して手書きスイッチにしている=Unicode テーブル由来の判定は `White_Space` が追加された時点で静かにズレ、この写しが防ごうとしている乖離そのものを再発させる。期待値は手で書かず、`-run` に `[`+対象ルーンを渡すと要素が必ず不正になりエラーが書き換え後の文字列をそのまま出力する性質を使って実測した。 +- 覆す条件: `testing` 側の `rewrite`/`isSpace` が変わった場合(固定 Go の `src/testing/match.go` を再 diff)。また、ミラーの手写しが破綻するなら、実 `go test -run` の出力を期待値として生成する照合を CI に入れる方向へ切り替える。 + +## 2026-09-20: 予約 import path の判定は綴りではなく `Module` の有無で行う + +- 却下した案: import path が `command-line-arguments` なら無条件で拒否する。 +- 決め手: `module command-line-arguments` を宣言したモジュールは実在でき、`go list -json ./...` が同じ import path を **`Module` 付き**で返し `go test command-line-arguments` は `ok` になる。一方 `.go` ファイル一覧指定では `Module: null` で返る。綴りだけで弾くと正当なモジュールを拒否する。直前の教訓「`.go` の判定は `go list` の解決結果で行う」と同じ誤りが、パターン側からセンチネル側へ移っていただけだった。判定は既に module framing 欠落を扱っている分岐へ移した。 +- 覆す条件: `go list` がファイル一覧に `Module` を付けて返すようになった場合。 + +## 2026-09-20: ミラーの忠実さは「どの Go で動くか」で決まる——`strconv.IsPrint` は写さず絞る + +- 却下した案: `testing.rewrite` を完全同一に写す(`!strconv.IsPrint(r)` をそのまま使う)。レビュー指摘の字面どおりの実装。 +- 決め手: `rewrite` はユーザーのツールチェーン内で動き、こちらのミラーは固定 1.26.8 でビルドされたバイナリ内で動く。`strconv.IsPrint` は Unicode バージョンごとに生成されるテーブルで、全ルーン走査の実測で **1.27.1 では印字可・1.26.8 では不可が 10,615 ルーン**あった。実害も再現済みで、U+088F を `-run` に渡すと 1.27.1 の `go test` は `ok`、1.26.8 は `࢏` にエスケープして拒否する。完全同一に写すと、Go 1.27 系ユーザーに対して有効な式を——`exactRun` が報告済みテスト名から自分で組み立てた rerun ごと——誤拒否する。`unicode.IsGraphic` は同じテーブル由来で連動する(1.26.8 で該当0件/1.27.1 で全件)ため代替軸にならない。ドリフトした 10,615 ルーンは 1.26.8 では**全て `Cn`(未割当)**で、逆に U+200B(Cf)・U+0007(Cc)・U+FEFF(Cf)・U+E000(Co) は安定側。よってエスケープを Cc/Cf/Cs/Co に限定した。取りこぼしは「素通しして受理」=修正前の挙動に戻るだけで、入力エラーの誤報にはならない安全側に倒れる。空白判定側は逆に手写しが正しい(`testing.isSpace` は固定スイッチ)ため、2つの分岐で判断が分かれる。 +- 覆す条件: `rewrite` の仕様自体が変わった場合。なお直前エントリの「固定 Go の `src/testing/match.go` を再 diff」は不正確で、`-run` を書き換えるのは**呼び出し側のツールチェーン**なので、固定版だけでなく対応したい最小・最大の Go 版で突き合わせる。あるいは実 `go test -run` の出力から期待値を生成する照合を CI に入れる。 diff --git a/test-runner/README.md b/test-runner/README.md index 198c20a..33595ae 100644 --- a/test-runner/README.md +++ b/test-runner/README.md @@ -16,8 +16,8 @@ agy plugin install https://github.com/kwrkb/agy-plugins/test-runner | Argument | Meaning | | :-- | :-- | | `module_path` | Required directory containing `go.mod`. Prefer an absolute path: relative paths use the MCP server's working directory. | -| `packages` | Nonempty string array of Go package patterns; default `["./..."]`. Local paths and import paths are accepted only when all resolved packages belong to the selected module and are physically inside it. | -| `run` | Optional Go `-run` expression, including slash-separated subtest expressions. | +| `packages` | Nonempty string array of Go package patterns; default `["./..."]`. Local paths and import paths are accepted only when all resolved packages belong to the selected module and are physically inside it. A package directory or import path may end in `.go`; a `.go` file list is rejected by its discovery result. | +| `run` | Optional Go `-run` expression, including slash-separated subtest expressions. An expression `go test` would reject at startup is reported as an input error instead of a test failure. | | `timeout_seconds` | Integer 1–300, default 60. Covers discovery, compilation, and tests. Process cleanup can take a few additional seconds. | Example arguments: @@ -46,7 +46,7 @@ Each MCP result contains a JSON text object: Use a failure's `rerun` object as the next `go_test` arguments. It retains the absolute module path and timeout, selects one package, and anchors/escapes each subtest-name segment. Go still runs parent setup to discover a selected subtest. Parent failures can overlap child failures; choose the parent to rerun a group or the child to narrow the retry. There is no server-side retry state. -Malformed/oversized JSON events (over 1 MiB), over 100,000 result records, and discovery output over 8 MiB produce explicit execution errors. Output continues to drain after a parsing error to avoid blocking the child; the overall timeout still applies. Timeout/cancellation stops the Unix process group or the Windows process tree and bounds pipe shutdown. Programs that deliberately detach from the process tree are outside this mechanism. +A `run` expression over 64 KiB is rejected as an input error, because it is validated before the timeout starts. Malformed/oversized JSON events (over 1 MiB), over 100,000 result records, and discovery output over 8 MiB produce explicit execution errors. Output continues to drain after a parsing error to avoid blocking the child; the overall timeout still applies. Timeout/cancellation stops the Unix process group or the Windows process tree and bounds pipe shutdown. Programs that deliberately detach from the process tree are outside this mechanism. ## Development and validation diff --git a/test-runner/README_ja.md b/test-runner/README_ja.md index eb2a488..cda00ce 100644 --- a/test-runner/README_ja.md +++ b/test-runner/README_ja.md @@ -16,8 +16,8 @@ agy plugin install https://github.com/kwrkb/agy-plugins/test-runner | 引数 | 内容 | | :-- | :-- | | `module_path` | 必須。`go.mod` があるディレクトリ。相対パスはMCPサーバーの作業ディレクトリ基準なので、絶対パスを推奨します。 | -| `packages` | 空でない文字列配列。既定 `["./..."]`。ローカルパス・import pathを受け付け、解決した全パッケージが対象モジュールに所属し、そのディレクトリ内にあることを確認します。 | -| `run` | 任意。Goの `-run` 正規表現。`/` 区切りのサブテスト指定にも対応します。 | +| `packages` | 空でない文字列配列。既定 `["./..."]`。ローカルパス・import pathを受け付け、解決した全パッケージが対象モジュールに所属し、そのディレクトリ内にあることを確認します。`.go` で終わるパッケージディレクトリ・import pathも指定でき、`.go` ファイル一覧は解決結果で拒否します。 | +| `run` | 任意。Goの `-run` 正規表現。`/` 区切りのサブテスト指定にも対応します。`go test` が起動時に拒否する式は、テスト失敗ではなく入力エラーとして返します。 | | `timeout_seconds` | 1~300の整数。既定60秒。探索・ビルド・テスト全体の制限です。停止処理には追加で数秒かかる場合があります。 | 引数例: diff --git a/test-runner/bin/test-runner-darwin-arm64 b/test-runner/bin/test-runner-darwin-arm64 index 7932da2..19b14f8 100755 Binary files a/test-runner/bin/test-runner-darwin-arm64 and b/test-runner/bin/test-runner-darwin-arm64 differ diff --git a/test-runner/bin/test-runner-linux-amd64 b/test-runner/bin/test-runner-linux-amd64 index 4cbb3e8..4604fc0 100755 Binary files a/test-runner/bin/test-runner-linux-amd64 and b/test-runner/bin/test-runner-linux-amd64 differ diff --git a/test-runner/bin/test-runner.exe b/test-runner/bin/test-runner.exe index fee5531..098ce09 100755 Binary files a/test-runner/bin/test-runner.exe and b/test-runner/bin/test-runner.exe differ diff --git a/test-runner/skills/test-runner-gemini/SKILL.md b/test-runner/skills/test-runner-gemini/SKILL.md index bd1326d..2b4d328 100644 --- a/test-runner/skills/test-runner-gemini/SKILL.md +++ b/test-runner/skills/test-runner-gemini/SKILL.md @@ -12,8 +12,8 @@ description: go_test MCP ツールを使用して Go テストを実行し、失 `go_test` を呼び出す際は、`go.mod` が存在するディレクトリの絶対パスを `module_path` に指定します。MCP サーバーのカレントワーキングディレクトリはリポジトリルートと異なる場合があるため、相対パスではなく絶対パスを推奨します。 - **`module_path`** (string, 必須): `go.mod` が置かれているディレクトリの絶対パス。マルチモジュール構成のリポジトリでは、対象モジュールごとに個別に呼び出します。 -- **`packages`** (array of string, 任意): パッケージパターン。既定値は `["./..."]`。フラグや `.go` ファイル一覧の指定は不可。 -- **`run`** (string, 任意): Go の `-run` 正規表現(サブテストの `/` 区切り構文に対応)。 +- **`packages`** (array of string, 任意): パッケージパターン。既定値は `["./..."]`。フラグの指定は不可。`.go` ファイル一覧も不可だが、`.go` で終わるパッケージディレクトリ・import path は指定できる。 +- **`run`** (string, 任意): Go の `-run` 正規表現(サブテストの `/` 区切り構文に対応)。`go test` が起動時に拒否する不正な式は、テスト失敗ではなく `status: "error"` として返ります。64 KiB を超える式も同様に入力エラーになります。 - **`timeout_seconds`** (integer, 任意): パッケージ探索・コンパイル・テスト実行全体の上限時間(1〜300秒、既定60秒)。 ## 実行結果の確認ポイント @@ -24,7 +24,7 @@ description: go_test MCP ツールを使用して Go テストを実行し、失 - `passed`: 失敗なし(全件スキップの場合も含む。下記 `tests` を参照) - `failed`: テスト失敗またはビルド失敗(MCP ツールとしては正常終了扱い) - `no_tests`: テストの終了イベントが 0 件(実行対象のテストが無かった)。**関連テストが通った証拠にはならない** - - `error` / `timeout` / `cancelled`: ツールエラー、タイムアウト、キャンセル中断(MCP の `isError` が true) + - `error` / `timeout` / `cancelled`: ツールエラー(不正な引数を含む)、タイムアウト、キャンセル中断(MCP の `isError` が true) これらをテスト成功として報告してはいけません。 2. **`tests`**: - `passed`・`failed`・`skipped` の件数。**親テストとサブテストの両方を含みます**。 diff --git a/test-runner/src/events.go b/test-runner/src/events.go index e5ba964..6ce657d 100644 --- a/test-runner/src/events.go +++ b/test-runner/src/events.go @@ -6,7 +6,9 @@ import ( "fmt" "regexp" "sort" + "strconv" "strings" + "unicode" "unicode/utf8" ) @@ -15,6 +17,13 @@ const returnedLogLimit = 64 << 10 const eventLimit = 1 << 20 const recordLimit = 100000 +// runLimit bounds the -run expression. validateRun compiles one regexp per +// top-level element and runs before the request timeout context exists, so an +// unbounded expression would spend that time outside the timeout and outside +// elapsed_seconds. At this size validation measures in tens of milliseconds, +// against roughly a second for a 2 MiB expression. +const runLimit = 64 << 10 + type Counts struct { Passed int `json:"passed"` Failed int `json:"failed"` @@ -204,6 +213,130 @@ func (s *eventStream) consume(line []byte) { } } func terminal(a string) bool { return a == "pass" || a == "fail" || a == "skip" } + +// splitRun mirrors testing.splitRegexp: go test cuts a -run expression into +// elements at slashes and alternations that sit outside brackets, groups and +// escapes, then compiles each element on its own. The escape case is load +// bearing: without it an escaped separator such as `a\/b` splits into `a\`, +// which does not compile, so a valid expression would be rejected. +func splitRun(expr string) []string { + var elements []string + brackets, groups := 0, 0 + for i := 0; i < len(expr); { + switch expr[i] { + case '[': + brackets++ + case ']': + if brackets--; brackets < 0 { // An unmatched ']' is legal. + brackets = 0 + } + case '(': + if brackets == 0 { + groups++ + } + case ')': + if brackets == 0 { + groups-- + } + case '\\': + i++ + case '/', '|': + if brackets == 0 && groups == 0 { + elements = append(elements, expr[:i]) + expr = expr[i+1:] + i = 0 + continue + } + } + i++ + } + return append(elements, expr) +} + +// validateRun rejects what the test binary would reject at startup. Left to go +// test, an invalid caller expression fails every package with no failing test, +// which reads as a project test failure rather than an input error. +func validateRun(expr string) error { + if expr == "" { + return nil + } + for i, element := range splitRun(expr) { + rewritten := rewriteRun(element) + if _, err := regexp.Compile(rewritten); err != nil { + shown := strconv.Quote(element) + if rewritten != element { + shown += fmt.Sprintf(" checked as %q", rewritten) + } + return fmt.Errorf("run element %d (%s) is not a valid expression: %w", i, shown, err) + } + } + return nil +} + +// rewriteRun mirrors testing.rewrite, which go test applies to every -run +// element before compiling it: whitespace collapses to '_' and non-printable +// runes become their escaped spelling. Both substitutions decide whether an +// element compiles, in either direction: a lone U+200B becomes the escape +// "\u200b" that the regexp parser rejects, while a backslash before it becomes +// an escaped backslash that parses. +// +// The escape branch deliberately narrows the mirror, because testing.rewrite +// runs inside the toolchain under test while this runs inside ours, and +// strconv.IsPrint is generated per Unicode version: 10615 runes are printable +// under 1.27.1 and not under the pinned 1.26.8. Escaping those would reject +// expressions the caller's go test accepts -- including reruns this server +// builds itself from reported test names. Every one of them is unassigned +// here, so the escape is limited to categories a later Unicode version cannot +// turn printable. A rune this misses stays raw and is merely accepted, which +// is the pre-existing behaviour, rather than wrongly reported as bad input. +func rewriteRun(s string) string { + var b strings.Builder + for _, r := range s { + switch { + case isRunSpace(r): + b.WriteByte('_') + case !strconv.IsPrint(r) && stableNonPrint(r): + q := strconv.QuoteRune(r) + b.WriteString(q[1 : len(q)-1]) + default: + b.WriteRune(r) + } + } + return b.String() +} + +// stableNonPrint reports whether r is non-printable for a reason no Unicode +// release can revise: a control, format, surrogate or private-use code point. +// Unassigned code points are excluded precisely because assigning one is what +// makes a newer toolchain print it raw. +func stableNonPrint(r rune) bool { + return unicode.Is(unicode.Cc, r) || unicode.Is(unicode.Cf, r) || + unicode.Is(unicode.Cs, r) || unicode.Is(unicode.Co, r) +} + +// isRunSpace is testing.isSpace verbatim. unicode.IsSpace agrees with it on +// every rune today, but it is a hand-written switch precisely because it is not +// the Unicode Z class: deriving it from the Unicode tables instead would start +// diverging the moment a release adds a White_Space rune, which is the same +// mismatch this mirror exists to avoid. +func isRunSpace(r rune) bool { + if r < 0x2000 { + switch r { + case '\t', '\n', '\v', '\f', '\r', ' ', 0x85, 0xA0, 0x1680: + return true + } + } else { + if r <= 0x200a { + return true + } + switch r { + case 0x2028, 0x2029, 0x202f, 0x205f, 0x3000: + return true + } + } + return false +} + func exactRun(name string) string { parts := strings.Split(name, "/") for i, p := range parts { diff --git a/test-runner/src/main.go b/test-runner/src/main.go index c677854..da5b335 100644 --- a/test-runner/src/main.go +++ b/test-runner/src/main.go @@ -39,9 +39,11 @@ func parseOptions(args map[string]any) (Options, error) { return o, fmt.Errorf("packages must be a nonempty string array") } } + // A directory or import path may legitimately end in ".go", so file lists are + // rejected by their discovery result instead of by this pattern's spelling. for _, p := range o.Packages { - if p == "" || strings.HasPrefix(p, "-") || strings.ContainsAny(p, "\x00\r\n") || strings.HasSuffix(p, ".go") { - return o, fmt.Errorf("invalid package pattern %q (file lists and flags are not supported)", p) + if p == "" || strings.HasPrefix(p, "-") || strings.ContainsAny(p, "\x00\r\n") { + return o, fmt.Errorf("invalid package pattern %q (flags are not supported)", p) } } if v, exists := args["run"]; exists { @@ -49,6 +51,12 @@ func parseOptions(args map[string]any) (Options, error) { if !ok || strings.ContainsRune(o.Run, 0) { return o, fmt.Errorf("run must be a string without NUL bytes") } + if len(o.Run) > runLimit { + return o, fmt.Errorf("run must be at most %d bytes", runLimit) + } + if err := validateRun(o.Run); err != nil { + return o, err + } } if v, exists := args["timeout_seconds"]; exists { b, err := json.Marshal(v) diff --git a/test-runner/src/main_test.go b/test-runner/src/main_test.go index 40c3391..2447679 100644 --- a/test-runner/src/main_test.go +++ b/test-runner/src/main_test.go @@ -24,9 +24,10 @@ func TestOptions(t *testing.T) { {"module_path": ".", "packages": []any{".", 2}}, {"module_path": ".", "packages": []string{}}, {"module_path": ".", "packages": []string{"-exec=evil"}}, - {"module_path": ".", "packages": []string{"a.go"}}, {"module_path": ".", "packages": nil}, {"module_path": ".", "run": false}, + {"module_path": ".", "run": "["}, + {"module_path": ".", "run": "A/("}, {"module_path": ".", "timeout_seconds": "60"}, {"module_path": ".", "timeout_seconds": 0}, {"module_path": ".", "timeout_seconds": 301}, @@ -44,6 +45,123 @@ func TestOptions(t *testing.T) { t.Fatalf("%+v %v", o, err) } } + // A directory or import path may end in ".go"; only discovery can tell such a + // package from a file list, so the pattern itself is accepted here. + o, err := parseOptions(map[string]any{"module_path": ".", "packages": []string{"./pkg.go", "example.com/m/gen.go"}}) + if err != nil || len(o.Packages) != 2 { + t.Fatalf("%+v %v", o, err) + } + // Expressions go test accepts, verified against a real "go test -run" run. + for _, run := range []string{"TestA", "(a/b)", "a[/]b", "A|B", "^A$/^B$", `^TestParent$/^a\+b\[1\]$`, "A//B", "A|", "(?Px)", "a b", `a\ b`, `a\/b`, `a\|b`, "", "\u088f", "A/\u088f", + // testing.rewrite escapes non-printable runes before compiling, so a + // backslash before one becomes an escaped backslash rather than an + // invalid escape, and "\a" stays the bell class it already was. + "\\\u200b", "\u0007", "a\u0007b", "\u00a0", "A/\\\u200b"} { + if o, err := parseOptions(map[string]any{"module_path": ".", "run": run}); err != nil || o.Run != run { + t.Fatalf("rejected valid run %q: %v", run, err) + } + } + // Expressions the test binary rejects at startup, which would otherwise fail + // every package and read as a project test failure. + for _, run := range []string{"[", "(a", "A/[", "a|(", "A/b)", `a\`, "((A)/B", "[a-", "A/*", + // The same rewrite turns a bare non-printable rune into "\u200b", which + // the regexp parser rejects; go test then fails every package instead. + "\u200b", "A/\u200b", "A|\u200b", "(\u200b)"} { + if _, err := parseOptions(map[string]any{"module_path": ".", "run": run}); err == nil { + t.Errorf("accepted invalid run %q", run) + } + } +} + +// A failing package whose import path ends in ".go" must produce a rerun the +// tool can accept again; the pattern check used to reject its own output. +// The rewrite go test applies to every -run element decides whether the +// element compiles, so rewriteRun must reproduce it rune for rune. Each +// expectation below was read off a real "go test -run" run: feeding it "[" plus +// the rune makes the element invalid, and the resulting error echoes the +// rewritten form back. Note the space set is testing's hand-written switch, not +// the Unicode Z class -- U+200A rewrites to "_" while U+200B, one past the end +// of that range, becomes the escape "\u200b" the regexp parser rejects. +// validateRun compiles one regexp per top-level element before runTests builds +// the timeout context, so the expression is bounded by size rather than left to +// spend that time outside the timeout and outside elapsed_seconds. +func TestRunSizeIsBounded(t *testing.T) { + if _, err := parseOptions(map[string]any{"module_path": ".", "run": strings.Repeat("A", runLimit)}); err != nil { + t.Fatalf("rejected run at the limit: %v", err) + } + // Worst case for validation cost: every byte pair is its own element. + dense := strings.Repeat("A|", runLimit/2) + start := time.Now() + o, err := parseOptions(map[string]any{"module_path": ".", "run": dense}) + if err != nil || o.Run != dense { + t.Fatalf("rejected dense run at the limit: %v", err) + } + if d := time.Since(start); d > 2*time.Second { + t.Errorf("validating a run at the limit took %s", d) + } + for _, run := range []string{strings.Repeat("A", runLimit+1), strings.Repeat("A|", runLimit)} { + if _, err := parseOptions(map[string]any{"module_path": ".", "run": run}); err == nil { + t.Errorf("accepted run of %d bytes", len(run)) + } + } +} + +func TestRewriteRunMatchesGoTest(t *testing.T) { + for in, want := range map[string]string{ + "\t": "_", " ": "_", "\u0085": "_", "\u00a0": "_", "\u1680": "_", + "\u2000": "_", "\u2005": "_", "\u200a": "_", "\u2028": "_", + "\u202f": "_", "\u205f": "_", "\u3000": "_", + "\u200b": `\u200b`, "\a": `\a`, "\ufeff": `\ufeff`, "\ue000": `\ue000`, + // Unassigned under the pinned toolchain and printable under a newer one, + // so it must survive raw: "go test -run" accepts it on go1.27.1 and this + // server would otherwise reject a rerun it built itself. + "\u088f": "\u088f", + "TestA": "TestA", + "a b": "a_b", + "a\\\u200b": `a\\u200b`, + } { + if got := rewriteRun(in); got != want { + t.Errorf("rewriteRun(%q) = %q, want %q", in, got, want) + } + } +} + +func TestRerunRoundTripsThroughOptions(t *testing.T) { + s := newEventStream() + emit(s, + event{Action: "run", Package: "example.com/m/gen.go", Test: "TestParent/a+b[1]"}, + event{Action: "fail", Package: "example.com/m/gen.go", Test: "TestParent/a+b[1]"}, + event{Action: "fail", Package: "example.com/m/gen.go"}, + ) + s.finish() + r := s.result(Options{ModulePath: ".", Packages: []string{"./..."}, Timeout: 60}, &cappedBuffer{}) + if r.Status != "failed" || len(r.Packages) != 1 || len(r.Packages[0].Failures) != 1 { + t.Fatalf("%+v", r) + } + rerun := r.Packages[0].Failures[0].Rerun + b, err := json.Marshal(rerun) + if err != nil { + t.Fatal(err) + } + var args map[string]any + if err := json.Unmarshal(b, &args); err != nil { + t.Fatal(err) + } + o, err := parseOptions(args) + if err != nil { + t.Fatalf("rerun rejected: %v (%+v)", err, rerun) + } + if o.Packages[0] != "example.com/m/gen.go" || o.Run != rerun.Run { + t.Fatalf("%+v", o) + } + // go test spells non-printable runes in test names with their escape + // sequence, so exactRun must quote the backslash for the rerun to survive + // validateRun, which mirrors the same rewrite. + for _, name := range []string{`TestZero/a\u200bb`, "TestSpace/a b", "TestParent/a+b[1]"} { + if err := validateRun(exactRun(name)); err != nil { + t.Fatalf("rerun for %q rejected: %v", name, err) + } + } } func emit(s *eventStream, es ...event) { @@ -276,12 +394,21 @@ func TestPackageValidation(t *testing.T) { {Dir: outside, ImportPath: "p", Module: &struct{ Dir string }{root}}, {Dir: root, ImportPath: "p", Module: &struct{ Dir string }{outside}}, {Dir: root, ImportPath: "-bad", Module: &struct{ Dir string }{root}}, + // go list reports a .go file list under this synthesized import path and + // without module framing. + {Dir: root, ImportPath: "command-line-arguments"}, } { b, _ := json.Marshal(p) if _, err := validatePackages(b, root); err == nil { t.Fatalf("accepted %+v", p) } } + // A module may legitimately declare "command-line-arguments" as its path; go + // list then reports it with a module, and "go test" on it succeeds. + b, _ := json.Marshal(listedPackage{Dir: root, ImportPath: "command-line-arguments", Module: &struct{ Dir string }{root}}) + if got, err := validatePackages(b, root); err != nil || len(got) != 1 || got[0] != "command-line-arguments" { + t.Fatalf("rejected module named command-line-arguments: %v %v", got, err) + } link := filepath.Join(root, "link") if err := os.Symlink(outside, link); err == nil { p := listedPackage{Dir: link, ImportPath: "p", Module: &struct{ Dir string }{root}} diff --git a/test-runner/src/runner.go b/test-runner/src/runner.go index 1bcf2a6..d5878ec 100644 --- a/test-runner/src/runner.go +++ b/test-runner/src/runner.go @@ -51,6 +51,12 @@ func validatePackages(data []byte, root string) ([]string, error) { if p.Error != nil { return nil, fmt.Errorf("package discovery: %s", p.Error.Err) } + // go list synthesizes this import path for a .go file list, which has no + // module framing and cannot be reproduced by a rerun. A module that + // declares this path is reported with a module and stays valid. + if p.ImportPath == "command-line-arguments" { + return nil, errors.New(".go file lists are not supported; pass package patterns") + } return nil, fmt.Errorf("package %q is not in the selected module", p.ImportPath) } moduleDir, err := canonical(p.Module.Dir)