|
| 1 | +package tsctests |
| 2 | + |
| 3 | +import ( |
| 4 | + "fmt" |
| 5 | + "io" |
| 6 | + "path" |
| 7 | + "sort" |
| 8 | + "strings" |
| 9 | + "sync" |
| 10 | + |
| 11 | + "github.com/microsoft/typescript-go/internal/execute" |
| 12 | + "github.com/microsoft/typescript-go/internal/fswatch" |
| 13 | + "github.com/microsoft/typescript-go/internal/testutil/fsbaselineutil" |
| 14 | +) |
| 15 | + |
| 16 | +// MockWatchBackend implements execute.WatchBackend for testing. It |
| 17 | +// records all WatchDirectory calls so tests can verify that |
| 18 | +// the correct watches are registered. Events can be delivered through |
| 19 | +// SendEvents, which routes them only through watches whose paths |
| 20 | +// match, enforcing that tests fail if the wrong watches are set up. |
| 21 | +type MockWatchBackend struct { |
| 22 | + mu sync.Mutex |
| 23 | + Dirs map[string]*MockWatch |
| 24 | + DirectoryExists func(string) bool // if set, WatchDirectory fails for non-existent dirs |
| 25 | +} |
| 26 | + |
| 27 | +var _ execute.WatchBackend = (*MockWatchBackend)(nil) |
| 28 | + |
| 29 | +// NewMockWatchBackend creates a ready-to-use mock backend. |
| 30 | +func NewMockWatchBackend() *MockWatchBackend { |
| 31 | + return &MockWatchBackend{ |
| 32 | + Dirs: make(map[string]*MockWatch), |
| 33 | + } |
| 34 | +} |
| 35 | + |
| 36 | +// HasWatches reports whether any watches have been registered. |
| 37 | +func (m *MockWatchBackend) HasWatches() bool { |
| 38 | + m.mu.Lock() |
| 39 | + defer m.mu.Unlock() |
| 40 | + return len(m.Dirs) > 0 |
| 41 | +} |
| 42 | + |
| 43 | +// MockWatch records a single registered watch. |
| 44 | +type MockWatch struct { |
| 45 | + Path string |
| 46 | + Callback fswatch.WatchCallback |
| 47 | + Recursive bool |
| 48 | + Ignore func(string) bool |
| 49 | + Closed bool |
| 50 | +} |
| 51 | + |
| 52 | +func (w *MockWatch) Close() error { |
| 53 | + w.Closed = true |
| 54 | + return nil |
| 55 | +} |
| 56 | + |
| 57 | +func (m *MockWatchBackend) WatchDirectory(dir string, fn fswatch.WatchCallback, recursive bool, ignore func(string) bool) (io.Closer, error) { |
| 58 | + m.mu.Lock() |
| 59 | + defer m.mu.Unlock() |
| 60 | + if m.DirectoryExists != nil && !m.DirectoryExists(dir) { |
| 61 | + return nil, fmt.Errorf("directory does not exist: %s", dir) |
| 62 | + } |
| 63 | + w := &MockWatch{Path: dir, Callback: fn, Recursive: recursive, Ignore: ignore} |
| 64 | + m.Dirs[dir] = w |
| 65 | + return w, nil |
| 66 | +} |
| 67 | + |
| 68 | +// SendEvents routes events through the registered watch callbacks |
| 69 | +// that match each event's path. Directory watches match if the event |
| 70 | +// path is a child (or recursive descendant) of the watched directory. |
| 71 | +// Events that match no watch are silently dropped — this is by design |
| 72 | +// so that tests fail when the production code doesn't register the |
| 73 | +// needed watches. |
| 74 | +func (m *MockWatchBackend) SendEvents(events []fswatch.Event) { |
| 75 | + // Snapshot callbacks under the lock, then invoke outside the lock |
| 76 | + // to avoid deadlock if the callback re-enters the mock. |
| 77 | + m.mu.Lock() |
| 78 | + type target struct { |
| 79 | + cb fswatch.WatchCallback |
| 80 | + events []fswatch.Event |
| 81 | + } |
| 82 | + targets := make(map[*MockWatch]*target) |
| 83 | + |
| 84 | + for _, e := range events { |
| 85 | + // Check directory watches. |
| 86 | + for _, w := range m.Dirs { |
| 87 | + if w.Closed { |
| 88 | + continue |
| 89 | + } |
| 90 | + if w.Ignore != nil && w.Ignore(e.Path) { |
| 91 | + continue |
| 92 | + } |
| 93 | + if !pathIsUnder(e.Path, w.Path, w.Recursive) { |
| 94 | + continue |
| 95 | + } |
| 96 | + if t, ok := targets[w]; ok { |
| 97 | + t.events = append(t.events, e) |
| 98 | + } else { |
| 99 | + targets[w] = &target{cb: w.Callback, events: []fswatch.Event{e}} |
| 100 | + } |
| 101 | + } |
| 102 | + } |
| 103 | + m.mu.Unlock() |
| 104 | + |
| 105 | + for _, t := range targets { |
| 106 | + t.cb(t.events, nil) |
| 107 | + } |
| 108 | +} |
| 109 | + |
| 110 | +// SendChangedPaths converts a list of file changes into fswatch |
| 111 | +// events with appropriate event kinds and routes them through |
| 112 | +// registered watches via SendEvents. For new/modified files, it also |
| 113 | +// emits update events for their parent directories, simulating how |
| 114 | +// real filesystem watchers report directory events. |
| 115 | +func (m *MockWatchBackend) SendChangedPaths(changes []fsbaselineutil.FileChange) { |
| 116 | + events := make([]fswatch.Event, 0, len(changes)*2) |
| 117 | + seenDirs := make(map[string]struct{}) |
| 118 | + for _, c := range changes { |
| 119 | + kind := fswatch.EventUpdate |
| 120 | + if c.Deleted { |
| 121 | + kind = fswatch.EventDelete |
| 122 | + } |
| 123 | + events = append(events, fswatch.Event{Kind: kind, Path: c.Path}) |
| 124 | + // Emit update events for parent directories of changed files. |
| 125 | + // Real filesystem watchers deliver events to non-recursive watches |
| 126 | + // when a child directory is created, which the mock must replicate. |
| 127 | + dir := path.Dir(c.Path) |
| 128 | + for dir != "" && dir != "/" && dir != "." { |
| 129 | + if _, seen := seenDirs[dir]; seen { |
| 130 | + break |
| 131 | + } |
| 132 | + seenDirs[dir] = struct{}{} |
| 133 | + events = append(events, fswatch.Event{Kind: fswatch.EventUpdate, Path: dir}) |
| 134 | + parent := path.Dir(dir) |
| 135 | + if parent == dir { |
| 136 | + break |
| 137 | + } |
| 138 | + dir = parent |
| 139 | + } |
| 140 | + } |
| 141 | + m.SendEvents(events) |
| 142 | +} |
| 143 | + |
| 144 | +// pathIsUnder reports whether eventPath is inside dir. If recursive is |
| 145 | +// false, only direct children match. |
| 146 | +func pathIsUnder(eventPath, dir string, recursive bool) bool { |
| 147 | + if !strings.HasPrefix(eventPath, dir) { |
| 148 | + return false |
| 149 | + } |
| 150 | + rest := eventPath[len(dir):] |
| 151 | + if len(rest) == 0 { |
| 152 | + return false // exact match = the dir itself, not a child |
| 153 | + } |
| 154 | + if rest[0] != '/' { |
| 155 | + return false // e.g. dir="/foo", path="/foobar" |
| 156 | + } |
| 157 | + if !recursive { |
| 158 | + // Direct child only: no further '/' after the separator. |
| 159 | + return !strings.Contains(rest[1:], "/") |
| 160 | + } |
| 161 | + return true |
| 162 | +} |
| 163 | + |
| 164 | +// WatchState returns a deterministic, human-readable summary of all |
| 165 | +// active watches. This is intended to be included in test baselines |
| 166 | +// so that watch registration correctness is verified via snapshot diffs. |
| 167 | +func (m *MockWatchBackend) WatchState() string { |
| 168 | + m.mu.Lock() |
| 169 | + defer m.mu.Unlock() |
| 170 | + |
| 171 | + var b strings.Builder |
| 172 | + b.WriteString("Watch Registrations::\n") |
| 173 | + |
| 174 | + // Directory watches, sorted by path. |
| 175 | + var dirs []string |
| 176 | + for dir, w := range m.Dirs { |
| 177 | + if !w.Closed { |
| 178 | + dirs = append(dirs, dir) |
| 179 | + } |
| 180 | + } |
| 181 | + sort.Strings(dirs) |
| 182 | + |
| 183 | + b.WriteString("Directory watches::\n") |
| 184 | + if len(dirs) == 0 { |
| 185 | + b.WriteString(" (none)\n") |
| 186 | + } |
| 187 | + for _, d := range dirs { |
| 188 | + w := m.Dirs[d] |
| 189 | + if w.Recursive { |
| 190 | + fmt.Fprintf(&b, " %s (recursive)\n", d) |
| 191 | + } else { |
| 192 | + fmt.Fprintf(&b, " %s\n", d) |
| 193 | + } |
| 194 | + } |
| 195 | + |
| 196 | + return b.String() |
| 197 | +} |
0 commit comments