|
| 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