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
The table of contents is too big for display.
Diff view
Diff view
  •  
  •  
  •  
355 changes: 355 additions & 0 deletions internal/execute/tsctests/extract.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,355 @@
package tsctests

import (
"crypto/sha1"
"encoding/hex"
"fmt"
"go/format"
"io/fs"
"maps"
"os"
"path/filepath"
"regexp"
"slices"
"strconv"
"strings"
"sync"
"testing/fstest"
"time"

"github.com/microsoft/typescript-go/internal/repo"
)

// capturedFsEntry is a comparable snapshot of a single entry on the test file
// system. Exactly one of content or symlink is non-empty. mtime is included
// so that "touch without content change" edits can be detected.
type capturedFsEntry struct {
content string
symlink string
mtime time.Time
}

// capturedEditOp describes one observed effect of running a tscEdit's edit
// function, used to generate equivalent calls in the extracted test file.
type capturedEditOp struct {
op string // "write", "remove", "symlink"
path string
content string // for "write" (file content) or "symlink" (target path)
}

// captureFsSnapshot returns a comparable map of the current user-visible
// state of the test file system, excluding files that were auto-injected as
// default library files.
func captureFsSnapshot(sys *TestSys) map[string]capturedFsEntry {
snap := map[string]capturedFsEntry{}
libs := sys.fs.defaultLibs
m := sys.mapFs()
for path, file := range m.Entries() {
if libs != nil && libs.Has(path) {
continue
}
if file.Mode&fs.ModeSymlink != 0 {
target, _ := m.GetTargetOfSymlink(path)
snap[path] = capturedFsEntry{symlink: target, mtime: file.ModTime}
} else if file.Mode.IsRegular() {
snap[path] = capturedFsEntry{content: string(file.Data), mtime: file.ModTime}
}
}
return snap
}

// diffFsSnapshots reports the operations needed to transform before into
// after. Removals come first, then writes (sorted by path), so the generated
// code is deterministic.
func diffFsSnapshots(before, after map[string]capturedFsEntry) []capturedEditOp {
var ops []capturedEditOp
removed := make([]string, 0)
for path := range before {
if _, ok := after[path]; !ok {
removed = append(removed, path)
}
}
slices.Sort(removed)
for _, p := range removed {
ops = append(ops, capturedEditOp{op: "remove", path: p})
}
changed := make([]string, 0)
for path, ae := range after {
if be, ok := before[path]; !ok || be != ae {
changed = append(changed, path)
}
}
slices.Sort(changed)
for _, p := range changed {
e := after[p]
if e.symlink != "" {
ops = append(ops, capturedEditOp{op: "symlink", path: p, content: e.symlink})
} else {
ops = append(ops, capturedEditOp{op: "write", path: p, content: e.content})
}
}
return ops
}

// writeTestSourceFile emits a standalone _test.go file under
// internal/execute/tsctests/tests/ that, when executed, reproduces the given
// test scenario by constructing an equivalent [TestSpec] and invoking Run on
// it. Edit functions are reconstructed from the observed file system effects
// captured during the original run.
func (test *tscInput) writeTestSourceFile(scenario string, editOps [][]capturedEditOp) {
funcName := makeTestFuncName(test.getBaselineSubFolder(), scenario, test.subScenario)
source := test.renderTestSource(scenario, funcName, editOps)
if formatted, err := format.Source([]byte(source)); err == nil {
source = string(formatted)
}

outDir := filepath.Join(repo.RootPath(), "internal", "execute", "tsctests", "tests")
if err := os.MkdirAll(outDir, 0o755); err != nil {
panic(fmt.Errorf("tsctests: failed to create %s: %w", outDir, err))
}
testFileName := funcName + "_test.go"
testFileName = strings.ToLower(testFileName[:1]) + testFileName[1:]
outPath := filepath.Join(outDir, testFileName)
if existing, err := os.ReadFile(outPath); err == nil && string(existing) == source {
return
}
if err := os.WriteFile(outPath, []byte(source), 0o644); err != nil {
panic(fmt.Errorf("tsctests: failed to write %s: %w", outPath, err))
}
}

func (test *tscInput) renderTestSource(scenario string, funcName string, editOps [][]capturedEditOp) string {
needsVfstest := fileMapNeedsVfstest(test.files) || editOpsNeedVfstest(editOps)

var b strings.Builder
b.WriteString("// Code generated by tsctests; DO NOT EDIT.\n")
b.WriteString("\n")
b.WriteString("package tests\n")
b.WriteString("\n")
b.WriteString("import (\n")
b.WriteString("\t\"testing\"\n")
b.WriteString("\n")
b.WriteString("\t\"github.com/microsoft/typescript-go/internal/execute/tsctests\"\n")
if needsVfstest {
b.WriteString("\t\"github.com/microsoft/typescript-go/internal/vfs/vfstest\"\n")
}
b.WriteString(")\n")
b.WriteString("\n")

fmt.Fprintf(&b, "func Test%s(t *testing.T) {\n", funcName)
b.WriteString("\ttest := &tsctests.TestSpec{\n")
fmt.Fprintf(&b, "\t\tScenario: %s,\n", strconv.Quote(scenario))
fmt.Fprintf(&b, "\t\tSubScenario: %s,\n", strconv.Quote(test.subScenario))
if test.commandLineArgs != nil {
fmt.Fprintf(&b, "\t\tCommandLineArgs: %s,\n", formatStringSlice(test.commandLineArgs))
}
if test.cwd != "" {
fmt.Fprintf(&b, "\t\tCwd: %s,\n", strconv.Quote(test.cwd))
}
if test.ignoreCase {
b.WriteString("\t\tIgnoreCase: true,\n")
}
if test.windowsStyleRoot != "" {
fmt.Fprintf(&b, "\t\tWindowsStyleRoot: %s,\n", strconv.Quote(test.windowsStyleRoot))
}
if len(test.env) > 0 {
writeEnvField(&b, test.env, "\t\t")
}
if len(test.files) > 0 {
writeFilesField(&b, test.files, "\t\t")
}
b.WriteString("\t}\n")
b.WriteString("\ttest.Start(t)\n")
if len(test.edits) > 0 {
writeEditCalls(&b, test.edits, editOps, "\t")
}
b.WriteString("\ttest.End()\n")
b.WriteString("}\n")

return b.String()
}

func fileMapNeedsVfstest(files FileMap) bool {
for _, v := range files {
if mf, ok := v.(*fstest.MapFile); ok && mf.Mode&fs.ModeSymlink != 0 {
return true
}
}
return false
}

func editOpsNeedVfstest(editOps [][]capturedEditOp) bool {
for _, ops := range editOps {
for _, op := range ops {
if op.op == "symlink" {
return true
}
}
}
return false
}

func writeEnvField(b *strings.Builder, env map[string]string, indent string) {
fmt.Fprintf(b, "%sEnv: map[string]string{\n", indent)
keys := slices.Sorted(maps.Keys(env))
for _, k := range keys {
fmt.Fprintf(b, "%s\t%s: %s,\n", indent, strconv.Quote(k), strconv.Quote(env[k]))
}
fmt.Fprintf(b, "%s},\n", indent)
}

func writeFilesField(b *strings.Builder, files FileMap, indent string) {
fmt.Fprintf(b, "%sFiles: tsctests.FileMap{\n", indent)
keys := slices.Sorted(maps.Keys(files))
for _, k := range keys {
fmt.Fprintf(b, "%s\t%s: %s,\n", indent, strconv.Quote(k), formatFileMapValue(files[k]))
}
fmt.Fprintf(b, "%s},\n", indent)
}

func formatFileMapValue(v any) string {
switch tv := v.(type) {
case string:
return formatStringLiteral(tv)
case []byte:
return formatStringLiteral(string(tv))
case *fstest.MapFile:
if tv.Mode&fs.ModeSymlink != 0 {
target := string(tv.Data)
if !strings.HasPrefix(target, "/") {
target = "/" + target
}
return fmt.Sprintf("vfstest.Symlink(%s)", strconv.Quote(target))
}
return formatStringLiteral(string(tv.Data))
default:
return fmt.Sprintf("%q /* unsupported FileMap value type %T */", fmt.Sprintf("%v", v), v)
}
}

func writeEditCalls(b *strings.Builder, edits []*tscEdit, editOps [][]capturedEditOp, indent string) {
for i, e := range edits {
b.WriteString("\n")
fmt.Fprintf(b, "%stest.Edit(&tsctests.TestEdit{\n", indent)
if e.caption != "" {
fmt.Fprintf(b, "%s\tCaption: %s,\n", indent, strconv.Quote(e.caption))
}
if e.commandLineArgs != nil {
fmt.Fprintf(b, "%s\tCommandLineArgs: %s,\n", indent, formatStringSlice(e.commandLineArgs))
}
if e.expectedDiff != "" {
fmt.Fprintf(b, "%s\tExpectedDiff: %s,\n", indent, strconv.Quote(e.expectedDiff))
}
if i < len(editOps) && len(editOps[i]) > 0 {
fmt.Fprintf(b, "%s\tEdit: func(sys *tsctests.TestSys) {\n", indent)
for _, op := range editOps[i] {
switch op.op {
case "write":
fmt.Fprintf(b, "%s\t\tsys.WriteFile(%s, %s)\n", indent, strconv.Quote(op.path), formatStringLiteral(op.content))
case "remove":
fmt.Fprintf(b, "%s\t\tsys.Remove(%s)\n", indent, strconv.Quote(op.path))
case "symlink":
// TestSys does not yet expose a way to create symlinks at
// runtime; record the intent so a human can finish the
// edit by hand if needed.
fmt.Fprintf(b, "%s\t\t_ = vfstest.Symlink(%s) // TODO: create symlink at %s\n", indent, strconv.Quote(op.content), strconv.Quote(op.path))
}
}
fmt.Fprintf(b, "%s\t},\n", indent)
}
fmt.Fprintf(b, "%s})\n", indent)
}
}

// formatStringLiteral picks the most readable Go string literal for s. Raw
// string literals are preferred for multi-line content, falling back to
// double-quoted strings when the content contains characters that cannot
// appear in a raw literal (e.g. a backtick).
func formatStringLiteral(s string) string {
if shouldUseRawLiteral(s) {
return "`" + s + "`"
}
return strconv.Quote(s)
}

func shouldUseRawLiteral(s string) bool {
if strings.ContainsAny(s, "`\r") {
return false
}
if !strings.Contains(s, "\n") {
return false
}
for _, r := range s {
// Raw strings preserve every byte literally, but they must contain
// only valid printable runes plus tab/newline to round-trip cleanly.
if r == '\t' || r == '\n' {
continue
}
if r < 0x20 || r == 0x7f {
return false
}
}
return true
}

func formatStringSlice(slice []string) string {
parts := make([]string, len(slice))
for i, s := range slice {
parts[i] = strconv.Quote(s)
}
return "[]string{" + strings.Join(parts, ", ") + "}"
}

var (
identSanitizer = regexp.MustCompile(`[^A-Za-z0-9]+`)

// usedFuncNames tracks generated test function names within this process
// so that two distinct (scenario, subScenario) pairs that sanitize to the
// same identifier do not silently overwrite each other.
usedFuncNames = map[string]struct{}{}
usedFuncNamesMu sync.Mutex
)

// makeTestFuncName produces a unique, valid Go identifier suitable for use as
// a test function name. It joins the supplied parts with underscores and
// appends a short hash suffix when the sanitized result would be ambiguous.
func makeTestFuncName(parts ...string) string {
var b strings.Builder
joinedRaw := strings.Join(parts, "/")
for i, p := range parts {
sanitized := identSanitizer.ReplaceAllString(p, "_")
sanitized = strings.Trim(sanitized, "_")
if sanitized == "" {
continue
}
if i != 0 {
b.WriteByte('_')
} else {
sanitized = strings.ToUpper(sanitized[:1]) + sanitized[1:] // Test function must start with uppercase
}

b.WriteString(sanitized)
}

const maxLen = 160
name := b.String()
if len(name) > maxLen {
sum := sha1.Sum([]byte(joinedRaw))
suffix := "_" + hex.EncodeToString(sum[:4])
name = name[:maxLen-len(suffix)] + suffix
}

usedFuncNamesMu.Lock()
defer usedFuncNamesMu.Unlock()
if _, taken := usedFuncNames[name]; taken {
sum := sha1.Sum([]byte(joinedRaw))
suffix := "_" + hex.EncodeToString(sum[:4])
candidate := name + suffix
if len(candidate) > maxLen {
candidate = name[:maxLen-len(suffix)] + suffix
}
name = candidate
}
usedFuncNames[name] = struct{}{}
return name
}
Loading
Loading