Skip to content

Commit f8d66fd

Browse files
committed
Extract tscInput tests to individual per-scenario files with imperative edits to improve debuggability
1 parent 654d05c commit f8d66fd

462 files changed

Lines changed: 28580 additions & 64 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.
Lines changed: 349 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,349 @@
1+
package tsctests
2+
3+
import (
4+
"crypto/sha1"
5+
"encoding/hex"
6+
"fmt"
7+
"go/format"
8+
"io/fs"
9+
"maps"
10+
"os"
11+
"path/filepath"
12+
"regexp"
13+
"slices"
14+
"strconv"
15+
"strings"
16+
"sync"
17+
"testing/fstest"
18+
"time"
19+
20+
"github.com/microsoft/typescript-go/internal/repo"
21+
)
22+
23+
// capturedFsEntry is a comparable snapshot of a single entry on the test file
24+
// system. Exactly one of content or symlink is non-empty. mtime is included
25+
// so that "touch without content change" edits can be detected.
26+
type capturedFsEntry struct {
27+
content string
28+
symlink string
29+
mtime time.Time
30+
}
31+
32+
// capturedEditOp describes one observed effect of running a tscEdit's edit
33+
// function, used to generate equivalent calls in the extracted test file.
34+
type capturedEditOp struct {
35+
op string // "write", "remove", "symlink"
36+
path string
37+
content string // for "write" (file content) or "symlink" (target path)
38+
}
39+
40+
// captureFsSnapshot returns a comparable map of the current user-visible
41+
// state of the test file system, excluding files that were auto-injected as
42+
// default library files.
43+
func captureFsSnapshot(sys *TestSys) map[string]capturedFsEntry {
44+
snap := map[string]capturedFsEntry{}
45+
libs := sys.fs.defaultLibs
46+
m := sys.mapFs()
47+
for path, file := range m.Entries() {
48+
if libs != nil && libs.Has(path) {
49+
continue
50+
}
51+
if file.Mode&fs.ModeSymlink != 0 {
52+
target, _ := m.GetTargetOfSymlink(path)
53+
snap[path] = capturedFsEntry{symlink: target, mtime: file.ModTime}
54+
} else if file.Mode.IsRegular() {
55+
snap[path] = capturedFsEntry{content: string(file.Data), mtime: file.ModTime}
56+
}
57+
}
58+
return snap
59+
}
60+
61+
// diffFsSnapshots reports the operations needed to transform before into
62+
// after. Removals come first, then writes (sorted by path), so the generated
63+
// code is deterministic.
64+
func diffFsSnapshots(before, after map[string]capturedFsEntry) []capturedEditOp {
65+
var ops []capturedEditOp
66+
removed := make([]string, 0)
67+
for path := range before {
68+
if _, ok := after[path]; !ok {
69+
removed = append(removed, path)
70+
}
71+
}
72+
slices.Sort(removed)
73+
for _, p := range removed {
74+
ops = append(ops, capturedEditOp{op: "remove", path: p})
75+
}
76+
changed := make([]string, 0)
77+
for path, ae := range after {
78+
if be, ok := before[path]; !ok || be != ae {
79+
changed = append(changed, path)
80+
}
81+
}
82+
slices.Sort(changed)
83+
for _, p := range changed {
84+
e := after[p]
85+
if e.symlink != "" {
86+
ops = append(ops, capturedEditOp{op: "symlink", path: p, content: e.symlink})
87+
} else {
88+
ops = append(ops, capturedEditOp{op: "write", path: p, content: e.content})
89+
}
90+
}
91+
return ops
92+
}
93+
94+
// writeTestSourceFile emits a standalone _test.go file under
95+
// internal/execute/tsctests/tests/ that, when executed, reproduces the given
96+
// test scenario by constructing an equivalent [TestSpec] and invoking Run on
97+
// it. Edit functions are reconstructed from the observed file system effects
98+
// captured during the original run.
99+
func (test *tscInput) writeTestSourceFile(scenario string, editOps [][]capturedEditOp) {
100+
funcName := makeTestFuncName(test.getBaselineSubFolder(), scenario, test.subScenario)
101+
source := test.renderTestSource(scenario, funcName, editOps)
102+
if formatted, err := format.Source([]byte(source)); err == nil {
103+
source = string(formatted)
104+
}
105+
106+
outDir := filepath.Join(repo.RootPath(), "internal", "execute", "tsctests", "tests")
107+
if err := os.MkdirAll(outDir, 0o755); err != nil {
108+
panic(fmt.Errorf("tsctests: failed to create %s: %w", outDir, err))
109+
}
110+
outPath := filepath.Join(outDir, funcName+"_test.go")
111+
if existing, err := os.ReadFile(outPath); err == nil && string(existing) == source {
112+
return
113+
}
114+
if err := os.WriteFile(outPath, []byte(source), 0o644); err != nil {
115+
panic(fmt.Errorf("tsctests: failed to write %s: %w", outPath, err))
116+
}
117+
}
118+
119+
func (test *tscInput) renderTestSource(scenario string, funcName string, editOps [][]capturedEditOp) string {
120+
needsVfstest := fileMapNeedsVfstest(test.files) || editOpsNeedVfstest(editOps)
121+
122+
var b strings.Builder
123+
b.WriteString("// Code generated by tsctests; DO NOT EDIT.\n")
124+
b.WriteString("\n")
125+
b.WriteString("package tests\n")
126+
b.WriteString("\n")
127+
b.WriteString("import (\n")
128+
b.WriteString("\t\"testing\"\n")
129+
b.WriteString("\n")
130+
b.WriteString("\t\"github.com/microsoft/typescript-go/internal/execute/tsctests\"\n")
131+
if needsVfstest {
132+
b.WriteString("\t\"github.com/microsoft/typescript-go/internal/vfs/vfstest\"\n")
133+
}
134+
b.WriteString(")\n")
135+
b.WriteString("\n")
136+
137+
fmt.Fprintf(&b, "func %s(t *testing.T) {\n", funcName)
138+
b.WriteString("\ttest := &tsctests.TestSpec{\n")
139+
fmt.Fprintf(&b, "\t\tScenario: %s,\n", strconv.Quote(scenario))
140+
fmt.Fprintf(&b, "\t\tSubScenario: %s,\n", strconv.Quote(test.subScenario))
141+
if test.commandLineArgs != nil {
142+
fmt.Fprintf(&b, "\t\tCommandLineArgs: %s,\n", formatStringSlice(test.commandLineArgs))
143+
}
144+
if test.cwd != "" {
145+
fmt.Fprintf(&b, "\t\tCwd: %s,\n", strconv.Quote(test.cwd))
146+
}
147+
if test.ignoreCase {
148+
b.WriteString("\t\tIgnoreCase: true,\n")
149+
}
150+
if test.windowsStyleRoot != "" {
151+
fmt.Fprintf(&b, "\t\tWindowsStyleRoot: %s,\n", strconv.Quote(test.windowsStyleRoot))
152+
}
153+
if len(test.env) > 0 {
154+
writeEnvField(&b, test.env, "\t\t")
155+
}
156+
if len(test.files) > 0 {
157+
writeFilesField(&b, test.files, "\t\t")
158+
}
159+
b.WriteString("\t}\n")
160+
b.WriteString("\ttest.Start(t)\n")
161+
if len(test.edits) > 0 {
162+
writeEditCalls(&b, test.edits, editOps, "\t")
163+
}
164+
b.WriteString("\ttest.End()\n")
165+
b.WriteString("}\n")
166+
167+
return b.String()
168+
}
169+
170+
func fileMapNeedsVfstest(files FileMap) bool {
171+
for _, v := range files {
172+
if mf, ok := v.(*fstest.MapFile); ok && mf.Mode&fs.ModeSymlink != 0 {
173+
return true
174+
}
175+
}
176+
return false
177+
}
178+
179+
func editOpsNeedVfstest(editOps [][]capturedEditOp) bool {
180+
for _, ops := range editOps {
181+
for _, op := range ops {
182+
if op.op == "symlink" {
183+
return true
184+
}
185+
}
186+
}
187+
return false
188+
}
189+
190+
func writeEnvField(b *strings.Builder, env map[string]string, indent string) {
191+
fmt.Fprintf(b, "%sEnv: map[string]string{\n", indent)
192+
keys := slices.Sorted(maps.Keys(env))
193+
for _, k := range keys {
194+
fmt.Fprintf(b, "%s\t%s: %s,\n", indent, strconv.Quote(k), strconv.Quote(env[k]))
195+
}
196+
fmt.Fprintf(b, "%s},\n", indent)
197+
}
198+
199+
func writeFilesField(b *strings.Builder, files FileMap, indent string) {
200+
fmt.Fprintf(b, "%sFiles: tsctests.FileMap{\n", indent)
201+
keys := slices.Sorted(maps.Keys(files))
202+
for _, k := range keys {
203+
fmt.Fprintf(b, "%s\t%s: %s,\n", indent, strconv.Quote(k), formatFileMapValue(files[k]))
204+
}
205+
fmt.Fprintf(b, "%s},\n", indent)
206+
}
207+
208+
func formatFileMapValue(v any) string {
209+
switch tv := v.(type) {
210+
case string:
211+
return formatStringLiteral(tv)
212+
case []byte:
213+
return formatStringLiteral(string(tv))
214+
case *fstest.MapFile:
215+
if tv.Mode&fs.ModeSymlink != 0 {
216+
target := string(tv.Data)
217+
if !strings.HasPrefix(target, "/") {
218+
target = "/" + target
219+
}
220+
return fmt.Sprintf("vfstest.Symlink(%s)", strconv.Quote(target))
221+
}
222+
return formatStringLiteral(string(tv.Data))
223+
default:
224+
return fmt.Sprintf("%q /* unsupported FileMap value type %T */", fmt.Sprintf("%v", v), v)
225+
}
226+
}
227+
228+
func writeEditCalls(b *strings.Builder, edits []*tscEdit, editOps [][]capturedEditOp, indent string) {
229+
for i, e := range edits {
230+
b.WriteString("\n")
231+
fmt.Fprintf(b, "%stest.Edit(&tsctests.TestEdit{\n", indent)
232+
if e.caption != "" {
233+
fmt.Fprintf(b, "%s\tCaption: %s,\n", indent, strconv.Quote(e.caption))
234+
}
235+
if e.commandLineArgs != nil {
236+
fmt.Fprintf(b, "%s\tCommandLineArgs: %s,\n", indent, formatStringSlice(e.commandLineArgs))
237+
}
238+
if e.expectedDiff != "" {
239+
fmt.Fprintf(b, "%s\tExpectedDiff: %s,\n", indent, strconv.Quote(e.expectedDiff))
240+
}
241+
if i < len(editOps) && len(editOps[i]) > 0 {
242+
fmt.Fprintf(b, "%s\tEdit: func(sys *tsctests.TestSys) {\n", indent)
243+
for _, op := range editOps[i] {
244+
switch op.op {
245+
case "write":
246+
fmt.Fprintf(b, "%s\t\tsys.WriteFile(%s, %s)\n", indent, strconv.Quote(op.path), formatStringLiteral(op.content))
247+
case "remove":
248+
fmt.Fprintf(b, "%s\t\tsys.Remove(%s)\n", indent, strconv.Quote(op.path))
249+
case "symlink":
250+
// TestSys does not yet expose a way to create symlinks at
251+
// runtime; record the intent so a human can finish the
252+
// edit by hand if needed.
253+
fmt.Fprintf(b, "%s\t\t_ = vfstest.Symlink(%s) // TODO: create symlink at %s\n", indent, strconv.Quote(op.content), strconv.Quote(op.path))
254+
}
255+
}
256+
fmt.Fprintf(b, "%s\t},\n", indent)
257+
}
258+
fmt.Fprintf(b, "%s})\n", indent)
259+
}
260+
}
261+
262+
// formatStringLiteral picks the most readable Go string literal for s. Raw
263+
// string literals are preferred for multi-line content, falling back to
264+
// double-quoted strings when the content contains characters that cannot
265+
// appear in a raw literal (e.g. a backtick).
266+
func formatStringLiteral(s string) string {
267+
if shouldUseRawLiteral(s) {
268+
return "`" + s + "`"
269+
}
270+
return strconv.Quote(s)
271+
}
272+
273+
func shouldUseRawLiteral(s string) bool {
274+
if strings.ContainsAny(s, "`\r") {
275+
return false
276+
}
277+
if !strings.Contains(s, "\n") {
278+
return false
279+
}
280+
for _, r := range s {
281+
// Raw strings preserve every byte literally, but they must contain
282+
// only valid printable runes plus tab/newline to round-trip cleanly.
283+
if r == '\t' || r == '\n' {
284+
continue
285+
}
286+
if r < 0x20 || r == 0x7f {
287+
return false
288+
}
289+
}
290+
return true
291+
}
292+
293+
func formatStringSlice(slice []string) string {
294+
parts := make([]string, len(slice))
295+
for i, s := range slice {
296+
parts[i] = strconv.Quote(s)
297+
}
298+
return "[]string{" + strings.Join(parts, ", ") + "}"
299+
}
300+
301+
var (
302+
identSanitizer = regexp.MustCompile(`[^A-Za-z0-9]+`)
303+
304+
// usedFuncNames tracks generated test function names within this process
305+
// so that two distinct (scenario, subScenario) pairs that sanitize to the
306+
// same identifier do not silently overwrite each other.
307+
usedFuncNames = map[string]struct{}{}
308+
usedFuncNamesMu sync.Mutex
309+
)
310+
311+
// makeTestFuncName produces a unique, valid Go identifier suitable for use as
312+
// a test function name. It joins the supplied parts with underscores and
313+
// appends a short hash suffix when the sanitized result would be ambiguous.
314+
func makeTestFuncName(parts ...string) string {
315+
var b strings.Builder
316+
b.WriteString("Test")
317+
joinedRaw := strings.Join(parts, "/")
318+
for _, p := range parts {
319+
sanitized := identSanitizer.ReplaceAllString(p, "_")
320+
sanitized = strings.Trim(sanitized, "_")
321+
if sanitized == "" {
322+
continue
323+
}
324+
b.WriteByte('_')
325+
b.WriteString(sanitized)
326+
}
327+
328+
const maxLen = 160
329+
name := b.String()
330+
if len(name) > maxLen {
331+
sum := sha1.Sum([]byte(joinedRaw))
332+
suffix := "_" + hex.EncodeToString(sum[:4])
333+
name = name[:maxLen-len(suffix)] + suffix
334+
}
335+
336+
usedFuncNamesMu.Lock()
337+
defer usedFuncNamesMu.Unlock()
338+
if _, taken := usedFuncNames[name]; taken {
339+
sum := sha1.Sum([]byte(joinedRaw))
340+
suffix := "_" + hex.EncodeToString(sum[:4])
341+
candidate := name + suffix
342+
if len(candidate) > maxLen {
343+
candidate = name[:maxLen-len(suffix)] + suffix
344+
}
345+
name = candidate
346+
}
347+
usedFuncNames[name] = struct{}{}
348+
return name
349+
}

0 commit comments

Comments
 (0)