Skip to content

Commit d6ad316

Browse files
committed
Add check-selectors command and tooling
Introduce a new CLI command `check-selectors` that parses Motus locator calls from C# sources and validates them against live pages. Adds CheckSelectorsRunner to orchestrate the flow, SelectorParser (Roslyn) to extract locator calls, LocatorDispatcher to map parsed selectors to IPage locators, FingerprintScanner and SuggestionBuilder to find replacement candidates from manifest fingerprints, and SelectorCheckTablePrinter to render results. Supports --manifest (page URLs + fingerprints), --base-url, --ci (fail on broken selectors), and --json output. Wires the command into Program.cs, adds package references for Microsoft.CodeAnalysis.CSharp and Microsoft.Extensions.FileSystemGlobbing, and includes unit and integration tests for parsing, dispatching, printing, and end-to-end behavior.
1 parent 3ee1da1 commit d6ad316

17 files changed

Lines changed: 1758 additions & 0 deletions
Lines changed: 64 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,64 @@
1+
using System.CommandLine;
2+
using Motus.Cli.Services;
3+
4+
namespace Motus.Cli.Commands;
5+
6+
public static class CheckSelectorsCommand
7+
{
8+
public static Command Build()
9+
{
10+
var globArg = new Argument<string>("glob")
11+
{
12+
Description = "Glob pattern for C# source files to scan (e.g. \"./Tests/**/*.cs\")"
13+
};
14+
15+
var manifestOpt = new Option<string?>("--manifest")
16+
{
17+
Description = "Path to a *.selectors.json manifest. When provided, selectors are checked against their recorded PageUrl."
18+
};
19+
20+
var baseUrlOpt = new Option<string?>("--base-url")
21+
{
22+
Description = "Base URL to check every selector against. Required when --manifest is not provided."
23+
};
24+
25+
var ciOpt = new Option<bool>("--ci")
26+
{
27+
Description = "Exit with a non-zero status code if any selector is broken."
28+
};
29+
30+
var jsonOpt = new Option<string?>("--json")
31+
{
32+
Description = "Write the full check results as JSON to the given path."
33+
};
34+
35+
var cmd = new Command("check-selectors", "Validate recorded selectors against live pages")
36+
{
37+
globArg,
38+
manifestOpt,
39+
baseUrlOpt,
40+
ciOpt,
41+
jsonOpt,
42+
};
43+
44+
cmd.SetAction(async (parseResult, ct) =>
45+
{
46+
var glob = parseResult.GetValue(globArg)!;
47+
var manifest = parseResult.GetValue(manifestOpt);
48+
var baseUrl = parseResult.GetValue(baseUrlOpt);
49+
var ci = parseResult.GetValue(ciOpt);
50+
var jsonPath = parseResult.GetValue(jsonOpt);
51+
52+
if (manifest is null && baseUrl is null)
53+
{
54+
Console.Error.WriteLine("error: either --manifest or --base-url must be provided.");
55+
return 2;
56+
}
57+
58+
var runner = new CheckSelectorsRunner();
59+
return await runner.RunAsync(glob, manifest, baseUrl, ci, jsonPath, ct);
60+
});
61+
62+
return cmd;
63+
}
64+
}

src/Motus.Cli/Motus.Cli.csproj

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@
2626

2727
<ItemGroup>
2828
<PackageReference Include="System.CommandLine" Version="2.0.0-beta5.25277.114" />
29+
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="4.12.0" />
30+
<PackageReference Include="Microsoft.Extensions.FileSystemGlobbing" Version="8.0.0" />
2931
</ItemGroup>
3032

3133
<ItemGroup>

src/Motus.Cli/Program.cs

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@
1111
ScreenshotCommand.Build(),
1212
PdfCommand.Build(),
1313
TraceCommand.Build(),
14+
CheckSelectorsCommand.Build(),
1415
};
1516

1617
var config = new CommandLineConfiguration(root);
Lines changed: 285 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,285 @@
1+
using System.Text.Json;
2+
using Motus;
3+
using Motus.Abstractions;
4+
using Motus.Cli.Commands;
5+
using Motus.Selectors;
6+
7+
namespace Motus.Cli.Services;
8+
9+
/// <summary>
10+
/// Orchestrates <c>motus check-selectors</c>: parse C# sources for locator calls,
11+
/// navigate a real browser to the relevant page(s), record whether each selector
12+
/// still resolves to exactly one element, and emit a colored summary table plus
13+
/// optional JSON output. Returns the command's process exit code.
14+
/// </summary>
15+
internal sealed class CheckSelectorsRunner
16+
{
17+
private readonly TextWriter _stdout;
18+
private readonly TextWriter _stderr;
19+
private readonly bool _useColor;
20+
21+
internal CheckSelectorsRunner(
22+
TextWriter? stdout = null, TextWriter? stderr = null, bool? useColor = null)
23+
{
24+
_stdout = stdout ?? Console.Out;
25+
_stderr = stderr ?? Console.Error;
26+
_useColor = useColor ?? !Console.IsOutputRedirected;
27+
}
28+
29+
internal async Task<int> RunAsync(
30+
string glob,
31+
string? manifestPath,
32+
string? baseUrl,
33+
bool ci,
34+
string? jsonOutputPath,
35+
CancellationToken ct)
36+
{
37+
// 1. Parse selectors from C# sources.
38+
SelectorParseResult parseResult;
39+
try
40+
{
41+
parseResult = await SelectorParser.ParseGlobAsync(
42+
glob, Directory.GetCurrentDirectory(), ct).ConfigureAwait(false);
43+
}
44+
catch (Exception ex)
45+
{
46+
_stderr.WriteLine($"error: failed to parse sources: {ex.Message}");
47+
return 2;
48+
}
49+
50+
foreach (var w in parseResult.Warnings)
51+
_stderr.WriteLine($"warning: {w.SourceFile}:{w.SourceLine}: {w.Message}");
52+
53+
if (parseResult.Selectors.Count == 0)
54+
{
55+
_stdout.WriteLine("No locator calls found. Nothing to check.");
56+
return 0;
57+
}
58+
59+
// 2. Load manifest if supplied.
60+
SelectorManifest? manifest = null;
61+
if (manifestPath is not null)
62+
{
63+
try
64+
{
65+
var json = await File.ReadAllTextAsync(manifestPath, ct).ConfigureAwait(false);
66+
manifest = JsonSerializer.Deserialize(
67+
json, SelectorManifestJsonContext.Default.SelectorManifest);
68+
if (manifest is null)
69+
{
70+
_stderr.WriteLine($"error: manifest '{manifestPath}' deserialized to null.");
71+
return 2;
72+
}
73+
}
74+
catch (Exception ex)
75+
{
76+
_stderr.WriteLine($"error: failed to load manifest '{manifestPath}': {ex.Message}");
77+
return 2;
78+
}
79+
}
80+
81+
var manifestLookup = manifest is null
82+
? null
83+
: BuildManifestLookup(manifest);
84+
85+
// 3. Build URL groups + the initial result list (skipped rows already materialized).
86+
var results = new List<SelectorCheckResult>();
87+
var groups = new Dictionary<string, List<(ParsedSelector Parsed, SelectorEntry? Entry)>>(StringComparer.Ordinal);
88+
89+
foreach (var selector in parseResult.Selectors)
90+
{
91+
if (selector.IsInterpolated)
92+
{
93+
results.Add(ToSkipped(selector,
94+
pageUrl: "",
95+
note: "interpolated selector cannot be validated statically"));
96+
continue;
97+
}
98+
99+
if (manifestLookup is not null)
100+
{
101+
if (!manifestLookup.TryGetValue((selector.Selector, selector.LocatorMethod), out var entry))
102+
{
103+
results.Add(ToSkipped(selector,
104+
pageUrl: "",
105+
note: "no manifest entry; cannot determine page URL"));
106+
continue;
107+
}
108+
109+
AddToGroup(groups, entry.PageUrl, selector, entry);
110+
}
111+
else
112+
{
113+
AddToGroup(groups, baseUrl!, selector, entry: null);
114+
}
115+
}
116+
117+
if (groups.Count == 0)
118+
{
119+
PrintAndWriteOutputs(results, jsonOutputPath);
120+
return ExitCodeFor(results, ci);
121+
}
122+
123+
// 4. Launch browser and check each URL group.
124+
IBrowser? browser = null;
125+
try
126+
{
127+
try
128+
{
129+
var launchOptions = new LaunchOptions
130+
{
131+
Headless = true,
132+
ExecutablePath = BrowserPathHelper.Resolve(),
133+
};
134+
browser = await MotusLauncher.LaunchAsync(launchOptions, ct).ConfigureAwait(false);
135+
}
136+
catch (FileNotFoundException)
137+
{
138+
_stderr.WriteLine("error: no browser found. Run 'motus install' first.");
139+
return 2;
140+
}
141+
catch (Exception ex)
142+
{
143+
_stderr.WriteLine($"error: failed to launch browser: {ex.Message}");
144+
return 2;
145+
}
146+
147+
var context = await browser.NewContextAsync().ConfigureAwait(false);
148+
var page = await context.NewPageAsync().ConfigureAwait(false);
149+
150+
foreach (var (url, items) in groups)
151+
{
152+
try
153+
{
154+
await page.GotoAsync(url, new NavigationOptions { WaitUntil = WaitUntil.Load })
155+
.ConfigureAwait(false);
156+
}
157+
catch (Exception ex)
158+
{
159+
foreach (var item in items)
160+
{
161+
results.Add(ToSkipped(item.Parsed,
162+
pageUrl: url,
163+
note: $"navigation failed: {ex.Message}"));
164+
}
165+
continue;
166+
}
167+
168+
foreach (var (parsed, entry) in items)
169+
{
170+
ct.ThrowIfCancellationRequested();
171+
results.Add(await CheckOneAsync(page, parsed, entry, url, ct).ConfigureAwait(false));
172+
}
173+
}
174+
}
175+
finally
176+
{
177+
if (browser is not null)
178+
await browser.CloseAsync().ConfigureAwait(false);
179+
}
180+
181+
PrintAndWriteOutputs(results, jsonOutputPath);
182+
return ExitCodeFor(results, ci);
183+
}
184+
185+
private async Task<SelectorCheckResult> CheckOneAsync(
186+
IPage page, ParsedSelector parsed, SelectorEntry? entry, string pageUrl, CancellationToken ct)
187+
{
188+
int count;
189+
try
190+
{
191+
var locator = LocatorDispatcher.Dispatch(page, parsed);
192+
count = await locator.CountAsync().ConfigureAwait(false);
193+
}
194+
catch (Exception ex)
195+
{
196+
return new SelectorCheckResult(
197+
SelectorCheckStatus.Broken,
198+
parsed.Selector, parsed.LocatorMethod, parsed.SourceFile, parsed.SourceLine,
199+
pageUrl, MatchCount: 0, Suggestion: null, Note: $"dispatch error: {ex.Message}");
200+
}
201+
202+
var status = count switch
203+
{
204+
0 => SelectorCheckStatus.Broken,
205+
1 => SelectorCheckStatus.Healthy,
206+
_ => SelectorCheckStatus.Ambiguous,
207+
};
208+
209+
string? suggestion = null;
210+
if (status == SelectorCheckStatus.Broken && entry is not null)
211+
{
212+
var candidate = await FingerprintScanner.FindMatchAsync(page, entry.Fingerprint, ct)
213+
.ConfigureAwait(false);
214+
if (candidate is not null)
215+
suggestion = SuggestionBuilder.Build(candidate);
216+
}
217+
218+
return new SelectorCheckResult(
219+
status,
220+
parsed.Selector, parsed.LocatorMethod, parsed.SourceFile, parsed.SourceLine,
221+
pageUrl, MatchCount: count, Suggestion: suggestion, Note: null);
222+
}
223+
224+
private void PrintAndWriteOutputs(List<SelectorCheckResult> results, string? jsonOutputPath)
225+
{
226+
SelectorCheckTablePrinter.Print(results, _stdout, _useColor);
227+
228+
if (jsonOutputPath is not null)
229+
{
230+
try
231+
{
232+
var dir = Path.GetDirectoryName(jsonOutputPath);
233+
if (!string.IsNullOrEmpty(dir))
234+
Directory.CreateDirectory(dir);
235+
var json = JsonSerializer.Serialize(
236+
results, CheckResultsJsonContext.Default.ListSelectorCheckResult);
237+
File.WriteAllText(jsonOutputPath, json);
238+
}
239+
catch (Exception ex)
240+
{
241+
_stderr.WriteLine($"warning: failed to write JSON output to '{jsonOutputPath}': {ex.Message}");
242+
}
243+
}
244+
}
245+
246+
private static int ExitCodeFor(IReadOnlyList<SelectorCheckResult> results, bool ci)
247+
{
248+
if (!ci)
249+
return 0;
250+
foreach (var r in results)
251+
{
252+
if (r.Status == SelectorCheckStatus.Broken)
253+
return 1;
254+
}
255+
return 0;
256+
}
257+
258+
private static Dictionary<(string Selector, string LocatorMethod), SelectorEntry> BuildManifestLookup(
259+
SelectorManifest manifest)
260+
{
261+
var map = new Dictionary<(string, string), SelectorEntry>();
262+
foreach (var entry in manifest.Entries)
263+
map[(entry.Selector, entry.LocatorMethod)] = entry;
264+
return map;
265+
}
266+
267+
private static void AddToGroup(
268+
Dictionary<string, List<(ParsedSelector, SelectorEntry?)>> groups,
269+
string url,
270+
ParsedSelector parsed,
271+
SelectorEntry? entry)
272+
{
273+
if (!groups.TryGetValue(url, out var list))
274+
{
275+
list = new List<(ParsedSelector, SelectorEntry?)>();
276+
groups[url] = list;
277+
}
278+
list.Add((parsed, entry));
279+
}
280+
281+
private static SelectorCheckResult ToSkipped(ParsedSelector s, string pageUrl, string note) =>
282+
new(SelectorCheckStatus.Skipped,
283+
s.Selector, s.LocatorMethod, s.SourceFile, s.SourceLine,
284+
pageUrl, MatchCount: 0, Suggestion: null, Note: note);
285+
}

0 commit comments

Comments
 (0)