mirror of
https://github.com/Smaug123/WoofWare.Expect
synced 2025-10-05 20:48:40 +00:00
Add ability to update snapshots (#4)
This commit is contained in:
23
.github/workflows/dotnet.yaml
vendored
23
.github/workflows/dotnet.yaml
vendored
@@ -124,6 +124,27 @@ jobs:
|
||||
# Verify that there is exactly one nupkg in the artifact that would be NuGet published
|
||||
run: if [[ $(find packed -maxdepth 1 -name 'WoofWare.Expect.*.nupkg' -printf c | wc -c) -ne "1" ]]; then exit 1; fi
|
||||
|
||||
analyzers:
|
||||
runs-on: ubuntu-latest
|
||||
permissions:
|
||||
security-events: write
|
||||
steps:
|
||||
- name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
with:
|
||||
fetch-depth: 0 # so that NerdBank.GitVersioning has access to history
|
||||
- name: Install Nix
|
||||
uses: cachix/install-nix-action@v31
|
||||
with:
|
||||
extra_nix_config: |
|
||||
access-tokens = github.com=${{ secrets.GITHUB_TOKEN }}
|
||||
- name: Prepare analyzers
|
||||
run: nix develop --command dotnet restore analyzers/analyzers.fsproj
|
||||
- name: Build project
|
||||
run: nix develop --command dotnet build ./WoofWare.Expect/WoofWare.Expect.fsproj
|
||||
- name: Run analyzers
|
||||
run: nix run .#fsharp-analyzers -- --project ./WoofWare.Expect/WoofWare.Expect.fsproj --analyzers-path ./.analyzerpackages/g-research.fsharp.analyzers/*/ --verbosity detailed --report ./analysis.sarif --treat-as-error GRA-STRING-001 GRA-STRING-002 GRA-STRING-003 GRA-UNIONCASE-001 GRA-INTERPOLATED-001 GRA-TYPE-ANNOTATE-001 GRA-VIRTUALCALL-001 GRA-IMMUTABLECOLLECTIONEQUALITY-001 GRA-JSONOPTS-001 GRA-LOGARGFUNCFULLAPP-001 GRA-DISPBEFOREASYNC-001 --exclude-analyzers PartialAppAnalyzer
|
||||
|
||||
github-release-dry-run:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [nuget-pack]
|
||||
@@ -167,7 +188,7 @@ jobs:
|
||||
|
||||
all-required-checks-complete:
|
||||
if: ${{ always() }}
|
||||
needs: [check-dotnet-format, check-nix-format, build, build-nix, flake-check, expected-pack, linkcheck]
|
||||
needs: [check-dotnet-format, check-nix-format, build, build-nix, flake-check, expected-pack, linkcheck, analyzers]
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: G-Research/common-actions/check-required-lite@2b7dc49cb14f3344fbe6019c14a31165e258c059
|
||||
|
@@ -5,8 +5,9 @@ An [expect-testing](https://blog.janestreet.com/the-joy-of-expect-tests/) librar
|
||||
|
||||
# Current status
|
||||
|
||||
Basic mechanism works, but I haven't yet decided how the ergonomic updating of the input text will work.
|
||||
Ideally it would edit the input AST, but I don't yet know if that's viable.
|
||||
The basic mechanism works.
|
||||
Snapshot updating is vibe-coded with Opus 4 and is purely text-based; I didn't want to use the F# compiler services because that's a pretty heavyweight dependency which should be confined to a separate test runner entity.
|
||||
It's not very well tested, and I expect it to be kind of brittle.
|
||||
|
||||
# How to use
|
||||
|
||||
@@ -35,6 +36,10 @@ let ``This test fails: plain text comparison of ToString`` () =
|
||||
}
|
||||
```
|
||||
|
||||
# Limitations
|
||||
|
||||
* The snapshot updating mechanism *requires* you to use verbatim string literals. While the test assertions will work correctly if you do `snapshot ("foo" + "bar" + f 3)`, for example, the updating code is liable to do something undefined in that case. Also do not use format strings (`$"blah"`).
|
||||
|
||||
# Licence
|
||||
|
||||
MIT.
|
||||
|
22
WoofWare.Expect.Test/Assembly.fs
Normal file
22
WoofWare.Expect.Test/Assembly.fs
Normal file
@@ -0,0 +1,22 @@
|
||||
namespace WoofWare.Expect.Test
|
||||
|
||||
open System
|
||||
open System.IO
|
||||
open System.Reflection
|
||||
|
||||
[<RequireQualifiedAccess>]
|
||||
module Assembly =
|
||||
|
||||
let getEmbeddedResource (assembly : Assembly) (name : string) : string =
|
||||
let names = assembly.GetManifestResourceNames ()
|
||||
|
||||
let names =
|
||||
names |> Seq.filter (fun s -> s.EndsWith (name, StringComparison.Ordinal))
|
||||
|
||||
use s =
|
||||
names
|
||||
|> Seq.exactlyOne
|
||||
|> assembly.GetManifestResourceStream
|
||||
|> fun s -> new StreamReader (s)
|
||||
|
||||
s.ReadToEnd ()
|
@@ -1,6 +1,5 @@
|
||||
namespace WoofWare.Expect.Test
|
||||
namespace WoofWare.Expect.Test
|
||||
|
||||
open System
|
||||
open WoofWare.Expect
|
||||
open NUnit.Framework
|
||||
|
||||
@@ -9,7 +8,7 @@ module SimpleTest =
|
||||
[<Test>]
|
||||
let ``JSON is resilient to whitespace changes`` () =
|
||||
expect {
|
||||
snapshotJson "123 "
|
||||
snapshotJson " 123 "
|
||||
return 123
|
||||
}
|
||||
|
||||
@@ -17,7 +16,7 @@ module SimpleTest =
|
||||
let ``Example of a failing test`` () =
|
||||
expect {
|
||||
snapshot
|
||||
"snapshot mismatch! snapshot at filepath.fs:99 (Example of a failing test) was:
|
||||
@"snapshot mismatch! snapshot at filepath.fs:99 (Example of a failing test) was:
|
||||
|
||||
- 123
|
||||
|
||||
@@ -39,6 +38,6 @@ actual was:
|
||||
[<Test>]
|
||||
let ``Basic example`` () =
|
||||
expect {
|
||||
snapshot "123"
|
||||
snapshot @"123"
|
||||
return 123
|
||||
}
|
||||
|
10
WoofWare.Expect.Test/SyntaxCases/AtStringOneLine.fs
Normal file
10
WoofWare.Expect.Test/SyntaxCases/AtStringOneLine.fs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace BigExample
|
||||
|
||||
open WoofWare.Expect
|
||||
|
||||
module MyModule =
|
||||
let foo () =
|
||||
expect {
|
||||
snapshot @"test ""quotes"" here"
|
||||
return 123
|
||||
}
|
14
WoofWare.Expect.Test/SyntaxCases/SingleQuoteManyLine.fs
Normal file
14
WoofWare.Expect.Test/SyntaxCases/SingleQuoteManyLine.fs
Normal file
@@ -0,0 +1,14 @@
|
||||
namespace BigExample
|
||||
|
||||
open WoofWare.Expect
|
||||
|
||||
module MyModule =
|
||||
let foo () =
|
||||
expect {
|
||||
snapshot
|
||||
"test
|
||||
with
|
||||
newlines"
|
||||
|
||||
return 123
|
||||
}
|
@@ -0,0 +1,13 @@
|
||||
namespace BigExample
|
||||
|
||||
open WoofWare.Expect
|
||||
|
||||
module MyModule =
|
||||
let foo () =
|
||||
expect {
|
||||
snapshot (* comment *)
|
||||
"""test
|
||||
"""
|
||||
|
||||
return 123
|
||||
}
|
10
WoofWare.Expect.Test/SyntaxCases/TripleQuoteOneLine.fs
Normal file
10
WoofWare.Expect.Test/SyntaxCases/TripleQuoteOneLine.fs
Normal file
@@ -0,0 +1,10 @@
|
||||
namespace BigExample
|
||||
|
||||
open WoofWare.Expect
|
||||
|
||||
module MyModule =
|
||||
let foo () =
|
||||
expect {
|
||||
snapshot """test"""
|
||||
return 123
|
||||
}
|
213
WoofWare.Expect.Test/TestSnapshotFinding.fs
Normal file
213
WoofWare.Expect.Test/TestSnapshotFinding.fs
Normal file
@@ -0,0 +1,213 @@
|
||||
namespace WoofWare.Expect.Test
|
||||
|
||||
open WoofWare.Expect
|
||||
open NUnit.Framework
|
||||
|
||||
[<TestFixture>]
|
||||
module TestSnapshotFinding =
|
||||
|
||||
type Dummy = class end
|
||||
|
||||
[<Test>]
|
||||
let ``Triple-quote, one line, one-line replacement`` () =
|
||||
let source =
|
||||
Assembly.getEmbeddedResource typeof<Dummy>.Assembly "TripleQuoteOneLine.fs"
|
||||
|> _.Split('\n')
|
||||
|
||||
expect {
|
||||
snapshot
|
||||
@"namespace BigExample
|
||||
|
||||
open WoofWare.Expect
|
||||
|
||||
module MyModule =
|
||||
let foo () =
|
||||
expect {
|
||||
snapshot @""replacement""
|
||||
return 123
|
||||
}
|
||||
"
|
||||
|
||||
return SnapshotUpdate.updateSnapshotAtLine source 8 "replacement" |> String.concat "\n"
|
||||
}
|
||||
|
||||
[<Test>]
|
||||
let ``Triple-quote, one line, multi-line replacement`` () =
|
||||
let source =
|
||||
Assembly.getEmbeddedResource typeof<Dummy>.Assembly "TripleQuoteOneLine.fs"
|
||||
|> _.Split('\n')
|
||||
|
||||
expect {
|
||||
snapshot
|
||||
@"namespace BigExample
|
||||
|
||||
open WoofWare.Expect
|
||||
|
||||
module MyModule =
|
||||
let foo () =
|
||||
expect {
|
||||
snapshot @""replacement
|
||||
more""
|
||||
return 123
|
||||
}
|
||||
"
|
||||
|
||||
return
|
||||
SnapshotUpdate.updateSnapshotAtLine source 8 "replacement\nmore"
|
||||
|> String.concat "\n"
|
||||
}
|
||||
|
||||
[<Test>]
|
||||
let ``At-string, one line, one-line replacement`` () =
|
||||
let source =
|
||||
Assembly.getEmbeddedResource typeof<Dummy>.Assembly "AtStringOneLine.fs"
|
||||
|> _.Split('\n')
|
||||
|
||||
expect {
|
||||
snapshot
|
||||
@"namespace BigExample
|
||||
|
||||
open WoofWare.Expect
|
||||
|
||||
module MyModule =
|
||||
let foo () =
|
||||
expect {
|
||||
snapshot @""replacement""
|
||||
return 123
|
||||
}
|
||||
"
|
||||
|
||||
return SnapshotUpdate.updateSnapshotAtLine source 8 "replacement" |> String.concat "\n"
|
||||
}
|
||||
|
||||
[<Test>]
|
||||
let ``At-string, one line, multi-line replacement`` () =
|
||||
let source =
|
||||
Assembly.getEmbeddedResource typeof<Dummy>.Assembly "AtStringOneLine.fs"
|
||||
|> _.Split('\n')
|
||||
|
||||
expect {
|
||||
snapshot
|
||||
@"namespace BigExample
|
||||
|
||||
open WoofWare.Expect
|
||||
|
||||
module MyModule =
|
||||
let foo () =
|
||||
expect {
|
||||
snapshot @""replacement
|
||||
more""
|
||||
return 123
|
||||
}
|
||||
"
|
||||
|
||||
return
|
||||
SnapshotUpdate.updateSnapshotAtLine source 8 "replacement\nmore"
|
||||
|> String.concat "\n"
|
||||
}
|
||||
|
||||
[<Test>]
|
||||
let ``Triple-quote, intervening comment, one-line replacement`` () =
|
||||
let source =
|
||||
Assembly.getEmbeddedResource typeof<Dummy>.Assembly "TripleQuoteInterveningComment.fs"
|
||||
|> _.Split('\n')
|
||||
|
||||
expect {
|
||||
snapshot
|
||||
@"namespace BigExample
|
||||
|
||||
open WoofWare.Expect
|
||||
|
||||
module MyModule =
|
||||
let foo () =
|
||||
expect {
|
||||
snapshot (* comment *)
|
||||
@""replacement""
|
||||
|
||||
return 123
|
||||
}
|
||||
"
|
||||
|
||||
return SnapshotUpdate.updateSnapshotAtLine source 8 "replacement" |> String.concat "\n"
|
||||
}
|
||||
|
||||
[<Test>]
|
||||
let ``Triple-quote, intervening comment, multi-line replacement`` () =
|
||||
let source =
|
||||
Assembly.getEmbeddedResource typeof<Dummy>.Assembly "TripleQuoteInterveningComment.fs"
|
||||
|> _.Split('\n')
|
||||
|
||||
expect {
|
||||
snapshot
|
||||
@"namespace BigExample
|
||||
|
||||
open WoofWare.Expect
|
||||
|
||||
module MyModule =
|
||||
let foo () =
|
||||
expect {
|
||||
snapshot (* comment *)
|
||||
@""replacement
|
||||
more""
|
||||
|
||||
return 123
|
||||
}
|
||||
"
|
||||
|
||||
return
|
||||
SnapshotUpdate.updateSnapshotAtLine source 8 "replacement\nmore"
|
||||
|> String.concat "\n"
|
||||
}
|
||||
|
||||
[<Test>]
|
||||
let ``Single-quote, many lines, one-line replacement`` () =
|
||||
let source =
|
||||
Assembly.getEmbeddedResource typeof<Dummy>.Assembly "SingleQuoteManyLine.fs"
|
||||
|> _.Split('\n')
|
||||
|
||||
expect {
|
||||
snapshot
|
||||
@"namespace BigExample
|
||||
|
||||
open WoofWare.Expect
|
||||
|
||||
module MyModule =
|
||||
let foo () =
|
||||
expect {
|
||||
snapshot
|
||||
@""replacement""
|
||||
|
||||
return 123
|
||||
}
|
||||
"
|
||||
|
||||
return SnapshotUpdate.updateSnapshotAtLine source 8 "replacement" |> String.concat "\n"
|
||||
}
|
||||
|
||||
[<Test>]
|
||||
let ``Single-quote, many lines, multi-line replacement`` () =
|
||||
let source =
|
||||
Assembly.getEmbeddedResource typeof<Dummy>.Assembly "SingleQuoteManyLine.fs"
|
||||
|> _.Split('\n')
|
||||
|
||||
expect {
|
||||
snapshot
|
||||
@"namespace BigExample
|
||||
|
||||
open WoofWare.Expect
|
||||
|
||||
module MyModule =
|
||||
let foo () =
|
||||
expect {
|
||||
snapshot
|
||||
@""replacement
|
||||
more""
|
||||
|
||||
return 123
|
||||
}
|
||||
"
|
||||
|
||||
return
|
||||
SnapshotUpdate.updateSnapshotAtLine source 8 "replacement\nmore"
|
||||
|> String.concat "\n"
|
||||
}
|
@@ -7,12 +7,19 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="SimpleTest.fs" />
|
||||
<Compile Include="TestSurface.fs" />
|
||||
<Compile Include="Assembly.fs" />
|
||||
<Compile Include="SimpleTest.fs" />
|
||||
<Compile Include="TestSnapshotFinding.fs" />
|
||||
<Compile Include="TestSurface.fs" />
|
||||
<EmbeddedResource Include="SyntaxCases\AtStringOneLine.fs" />
|
||||
<EmbeddedResource Include="SyntaxCases\SingleQuoteManyLine.fs" />
|
||||
<EmbeddedResource Include="SyntaxCases\TripleQuoteInterveningComment.fs" />
|
||||
<EmbeddedResource Include="SyntaxCases\TripleQuoteOneLine.fs" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="ApiSurface" Version="4.1.20" />
|
||||
<PackageReference Include="FsUnit" Version="7.0.1" />
|
||||
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="17.14.1"/>
|
||||
<PackageReference Include="NUnit" Version="4.3.2"/>
|
||||
<PackageReference Include="NUnit3TestAdapter" Version="5.0.0"/>
|
||||
|
6
WoofWare.Expect/AssemblyInfo.fs
Normal file
6
WoofWare.Expect/AssemblyInfo.fs
Normal file
@@ -0,0 +1,6 @@
|
||||
module internal WoofWare.Expect.AssemblyInfo
|
||||
|
||||
open System.Runtime.CompilerServices
|
||||
|
||||
[<assembly : InternalsVisibleTo("WoofWare.Expect.Test")>]
|
||||
do ()
|
@@ -1,5 +1,6 @@
|
||||
namespace WoofWare.Expect
|
||||
|
||||
open System.IO
|
||||
open System.Runtime.CompilerServices
|
||||
open System.Text.Json
|
||||
open System.Text.Json.Serialization
|
||||
@@ -35,12 +36,33 @@ module private Text =
|
||||
let predent (c : char) (s : string) =
|
||||
s.Split '\n' |> Seq.map (sprintf "%c %s" c) |> String.concat "\n"
|
||||
|
||||
/// <summary>Specify how the Expect computation expression treats failures.</summary>
|
||||
/// <remarks>You probably don't want to use this directly; use the computation expression definitions
|
||||
/// like <c>expect</c> in the <c>Builder</c> module instead.</remarks>
|
||||
type Mode =
|
||||
private
|
||||
| Assert
|
||||
| Update
|
||||
| AssertMockingSource of (string * int)
|
||||
|
||||
/// <summary>
|
||||
/// The builder which powers WoofWare.Expect.
|
||||
/// </summary>
|
||||
/// <remarks>You're not expected to construct this explicitly; it's a computation expression, available as <c>Builder.expect</c>.</remarks>
|
||||
/// <param name="applyChanges">When running the tests, instead of throwing an exception on failure, update the snapshot.</param>
|
||||
/// <param name="sourceOverride">Override the file path and line numbers reported in snapshots, so that your tests can be fully stable even on failure. (You almost certainly don't want to set this.)</param>
|
||||
type ExpectBuilder (?sourceOverride : string * int) =
|
||||
type ExpectBuilder (mode : Mode) =
|
||||
member private this.Mode = Unchecked.defaultof<Mode>
|
||||
|
||||
new (sourceOverride : string * int) = ExpectBuilder (Mode.AssertMockingSource sourceOverride)
|
||||
|
||||
new (update : bool)
|
||||
=
|
||||
if update then
|
||||
ExpectBuilder Mode.Update
|
||||
else
|
||||
ExpectBuilder Mode.Assert
|
||||
|
||||
/// Combine two `ExpectState`s. The first one is the "expected" snapshot; the second is the "actual".
|
||||
member _.Bind
|
||||
(state : ExpectState<YouHaveSuppliedMultipleSnapshots>, f : unit -> ExpectState<'U>)
|
||||
@@ -155,6 +177,35 @@ type ExpectBuilder (?sourceOverride : string * int) =
|
||||
|
||||
match state.Snapshot, state.Actual with
|
||||
| Some (snapshot, source), Some actual ->
|
||||
let raiseError (snapshot : string) (actual : string) : unit =
|
||||
match mode with
|
||||
| Mode.AssertMockingSource (mockSource, line) ->
|
||||
sprintf
|
||||
"snapshot mismatch! snapshot at %s:%i (%s) was:\n\n%s\n\nactual was:\n\n%s"
|
||||
mockSource
|
||||
line
|
||||
source.MemberName
|
||||
(snapshot |> Text.predent '-')
|
||||
(actual |> Text.predent '+')
|
||||
|> ExpectException
|
||||
|> raise
|
||||
| Mode.Assert ->
|
||||
sprintf
|
||||
"snapshot mismatch! snapshot at %s:%i (%s) was:\n\n%s\n\nactual was:\n\n%s"
|
||||
source.FilePath
|
||||
source.LineNumber
|
||||
source.MemberName
|
||||
(snapshot |> Text.predent '-')
|
||||
(actual |> Text.predent '+')
|
||||
|> ExpectException
|
||||
|> raise
|
||||
| Mode.Update ->
|
||||
let lines = File.ReadAllLines source.FilePath
|
||||
let oldContents = String.concat "\n" lines
|
||||
let lines = SnapshotUpdate.updateSnapshotAtLine lines source.LineNumber actual
|
||||
File.WriteAllLines (source.FilePath, lines)
|
||||
failwith ("Snapshot successfully updated. Previous contents:\n" + oldContents)
|
||||
|
||||
match snapshot with
|
||||
| SnapshotValue.Json snapshot ->
|
||||
let canonicalSnapshot = JsonDocument.Parse snapshot
|
||||
@@ -163,29 +214,25 @@ type ExpectBuilder (?sourceOverride : string * int) =
|
||||
JsonSerializer.Serialize (actual, options) |> JsonDocument.Parse
|
||||
|
||||
if not (JsonElement.DeepEquals (canonicalActual.RootElement, canonicalSnapshot.RootElement)) then
|
||||
sprintf
|
||||
"snapshot mismatch! snapshot at %s:%i (%s) was:\n\n%s\n\nactual was:\n\n%s"
|
||||
(sourceOverride |> Option.map fst |> Option.defaultValue source.FilePath)
|
||||
(sourceOverride |> Option.map snd |> Option.defaultValue source.LineNumber)
|
||||
source.MemberName
|
||||
(canonicalSnapshot.RootElement.ToString () |> Text.predent '-')
|
||||
(canonicalActual.RootElement.ToString () |> Text.predent '-')
|
||||
|> ExpectException
|
||||
|> raise
|
||||
raiseError (canonicalSnapshot.RootElement.ToString ()) (canonicalActual.RootElement.ToString ())
|
||||
else
|
||||
match mode with
|
||||
| Mode.Update ->
|
||||
failwith
|
||||
"Snapshot assertion passed, but we are in snapshot-updating mode. Use the `expect` builder instead of `expect'` to assert the contents of a snapshot."
|
||||
| _ -> ()
|
||||
|
||||
| SnapshotValue.BareString snapshot ->
|
||||
let actual = actual.ToString ()
|
||||
|
||||
if actual <> snapshot then
|
||||
sprintf
|
||||
"snapshot mismatch! snapshot at %s:%i (%s) was:\n\n%s\n\nactual was:\n\n%s"
|
||||
(sourceOverride |> Option.map fst |> Option.defaultValue source.FilePath)
|
||||
(sourceOverride |> Option.map snd |> Option.defaultValue source.LineNumber)
|
||||
source.MemberName
|
||||
(snapshot |> Text.predent '-')
|
||||
(actual |> Text.predent '+')
|
||||
|> ExpectException
|
||||
|> raise
|
||||
raiseError snapshot actual
|
||||
else
|
||||
match mode with
|
||||
| Mode.Update ->
|
||||
failwith
|
||||
"Snapshot assertion passed, but we are in snapshot-updating mode. Use the `expect` builder instead of `expect'` to assert the contents of a snapshot."
|
||||
| _ -> ()
|
||||
|
||||
| None, _ -> failwith "Must specify snapshot"
|
||||
| _, None -> failwith "Must specify actual value with 'return'"
|
||||
@@ -207,7 +254,32 @@ module Builder =
|
||||
///
|
||||
/// (That example expectation will fail, because the actual value 124 does not snapshot to the expected snapshot "123".)
|
||||
/// </remarks>
|
||||
let expect = ExpectBuilder ()
|
||||
let expect = ExpectBuilder false
|
||||
|
||||
/// <summary>The WoofWare.Expect builder, but in "replace snapshot on failure" mode.</summary>
|
||||
///
|
||||
/// <remarks>
|
||||
/// Take an existing failing snapshot test:
|
||||
///
|
||||
/// <code>
|
||||
/// expect {
|
||||
/// snapshot "123"
|
||||
/// return 124
|
||||
/// }
|
||||
/// </code>
|
||||
///
|
||||
/// Add the <c>'</c> marker to the <c>expect</c> builder:
|
||||
/// <code>
|
||||
/// expect' {
|
||||
/// snapshot "123"
|
||||
/// return 124
|
||||
/// }
|
||||
/// </code>
|
||||
///
|
||||
/// Rerun, and observe that the snapshot becomes updated.
|
||||
/// This rerun will throw an exception, to help make sure you don't commit the snapshot builder while it's flipped to "update" mode.
|
||||
/// </remarks>
|
||||
let expect' = ExpectBuilder true
|
||||
|
||||
/// <summary>
|
||||
/// This is the `expect` builder, but it mocks out the filepath reported on failure.
|
272
WoofWare.Expect/SnapshotUpdate.fs
Normal file
272
WoofWare.Expect/SnapshotUpdate.fs
Normal file
@@ -0,0 +1,272 @@
|
||||
namespace WoofWare.Expect
|
||||
|
||||
open System
|
||||
open System.Text.RegularExpressions
|
||||
|
||||
type private StringLiteralInfo =
|
||||
{
|
||||
StartLine : int
|
||||
StartColumn : int
|
||||
EndLine : int
|
||||
EndColumn : int
|
||||
Content : string
|
||||
}
|
||||
|
||||
override this.ToString () =
|
||||
sprintf "%i:%i to %i:%i: %s" this.StartLine this.StartColumn this.EndLine this.EndColumn this.Content
|
||||
|
||||
type private Position =
|
||||
{
|
||||
Line : int
|
||||
Column : int
|
||||
TotalOffset : int
|
||||
}
|
||||
|
||||
[<RequireQualifiedAccess>]
|
||||
module internal SnapshotUpdate =
|
||||
[<Literal>]
|
||||
let tripleQuote = "\"\"\""
|
||||
|
||||
/// Convert a string position to line/column
|
||||
let private positionToLineColumn (text : string) (offset : int) : Position =
|
||||
let rec loop (line : int) (col : int) (totalOffset : int) (i : int) : Position =
|
||||
if i >= text.Length || totalOffset = offset then
|
||||
{
|
||||
Line = line
|
||||
Column = col
|
||||
TotalOffset = totalOffset
|
||||
}
|
||||
elif text.[i] = '\n' then
|
||||
loop (line + 1) 0 (totalOffset + 1) (i + 1)
|
||||
else
|
||||
loop line (col + 1) (totalOffset + 1) (i + 1)
|
||||
|
||||
loop 0 0 0 0
|
||||
|
||||
/// Skip whitespace and comments, returning the position after them
|
||||
let rec private skipWhitespaceAndComments (text : string) (startPos : int) : int option =
|
||||
let rec skipComment (depth : int) (pos : int) : int option =
|
||||
if pos >= text.Length - 1 then
|
||||
None
|
||||
elif pos + 1 < text.Length && text.[pos] = '(' && text.[pos + 1] = '*' then
|
||||
skipComment (depth + 1) (pos + 2)
|
||||
elif pos + 1 < text.Length && text.[pos] = '*' && text.[pos + 1] = ')' then
|
||||
if depth = 1 then
|
||||
Some (pos + 2)
|
||||
else
|
||||
skipComment (depth - 1) (pos + 2)
|
||||
else
|
||||
skipComment depth (pos + 1)
|
||||
|
||||
let rec loop pos =
|
||||
if pos >= text.Length then
|
||||
None
|
||||
elif pos + 1 < text.Length && text.[pos] = '(' && text.[pos + 1] = '*' then
|
||||
skipComment 1 (pos + 2) |> Option.bind loop
|
||||
elif Char.IsWhiteSpace (text.[pos]) then
|
||||
loop (pos + 1)
|
||||
else
|
||||
Some pos
|
||||
|
||||
loop startPos
|
||||
|
||||
/// Parse a regular string literal
|
||||
let private parseRegularString (text : string) (startPos : int) : (string * int) option =
|
||||
let rec loop pos content escaped =
|
||||
if pos >= text.Length then
|
||||
None
|
||||
elif escaped then
|
||||
let unescaped =
|
||||
match text.[pos] with
|
||||
| 'n' -> "\n"
|
||||
| 'r' -> "\r"
|
||||
| 't' -> "\t"
|
||||
| '\\' -> "\\"
|
||||
| '"' -> "\""
|
||||
| c -> string<char> c
|
||||
|
||||
loop (pos + 1) (content + unescaped) false
|
||||
elif text.[pos] = '\\' then
|
||||
loop (pos + 1) content true
|
||||
elif text.[pos] = '"' then
|
||||
Some (content, pos + 1)
|
||||
else
|
||||
loop (pos + 1) (content + string<char> text.[pos]) false
|
||||
|
||||
loop (startPos + 1) "" false
|
||||
|
||||
/// Parse a verbatim string literal (@"...")
|
||||
let private parseVerbatimString (text : string) (startPos : int) : (string * int) option =
|
||||
let rec loop pos content =
|
||||
if pos >= text.Length then
|
||||
None
|
||||
elif pos + 1 < text.Length && text.[pos] = '"' && text.[pos + 1] = '"' then
|
||||
// Escaped quote in verbatim string
|
||||
loop (pos + 2) (content + "\"")
|
||||
elif text.[pos] = '"' then
|
||||
// End of string
|
||||
Some (content, pos + 1)
|
||||
else
|
||||
loop (pos + 1) (content + string<char> text.[pos])
|
||||
|
||||
// Skip the @" prefix
|
||||
loop (startPos + 2) ""
|
||||
|
||||
/// Parse a triple-quoted string literal
|
||||
let private parseTripleQuotedString (text : string) (startPos : int) : (string * int) option =
|
||||
// startPos points to the first "
|
||||
if
|
||||
startPos + 2 >= text.Length
|
||||
|| text.[startPos] <> '"'
|
||||
|| text.[startPos + 1] <> '"'
|
||||
|| text.[startPos + 2] <> '"'
|
||||
then
|
||||
None
|
||||
else
|
||||
let contentStart = startPos + 3
|
||||
let closePos = text.IndexOf (tripleQuote, contentStart, StringComparison.Ordinal)
|
||||
|
||||
if closePos = -1 then
|
||||
None
|
||||
else
|
||||
let content = text.Substring (contentStart, closePos - contentStart)
|
||||
Some (content, closePos + 3)
|
||||
|
||||
/// Find the string literal after a snapshot keyword
|
||||
let private findSnapshotString (lines : string[]) (snapshotLine : int) : StringLiteralInfo option =
|
||||
let startIdx = snapshotLine - 1
|
||||
|
||||
if startIdx >= lines.Length then
|
||||
None
|
||||
else
|
||||
// We need to include enough lines to capture multi-line strings
|
||||
// Take a reasonable number of lines after the snapshot line
|
||||
let maxLines = min 50 (lines.Length - startIdx)
|
||||
let relevantLines = lines |> Array.skip startIdx |> Array.take maxLines
|
||||
|
||||
let searchText = String.concat "\n" relevantLines
|
||||
|
||||
// Find snapshot keyword
|
||||
let snapshotMatch = Regex.Match (searchText, @"\b(snapshot|snapshotJson)\b")
|
||||
|
||||
if not snapshotMatch.Success then
|
||||
None
|
||||
else
|
||||
// Work with positions relative to searchText throughout
|
||||
let snapshotEnd = snapshotMatch.Index + snapshotMatch.Length
|
||||
|
||||
// Skip whitespace and comments after "snapshot"
|
||||
skipWhitespaceAndComments searchText snapshotEnd
|
||||
|> Option.bind (fun stringStart ->
|
||||
if stringStart >= searchText.Length then
|
||||
None
|
||||
else
|
||||
// Check what type of string literal we have
|
||||
let parseResult =
|
||||
if
|
||||
stringStart + 2 < searchText.Length
|
||||
&& searchText.[stringStart] = '"'
|
||||
&& searchText.[stringStart + 1] = '"'
|
||||
&& searchText.[stringStart + 2] = '"'
|
||||
then
|
||||
// Triple-quoted string
|
||||
parseTripleQuotedString searchText stringStart
|
||||
|> Option.map (fun (content, endPos) -> (content, stringStart, endPos))
|
||||
elif
|
||||
stringStart + 1 < searchText.Length
|
||||
&& searchText.[stringStart] = '@'
|
||||
&& searchText.[stringStart + 1] = '"'
|
||||
then
|
||||
// Verbatim string
|
||||
parseVerbatimString searchText stringStart
|
||||
|> Option.map (fun (content, endPos) -> (content, stringStart, endPos))
|
||||
elif searchText.[stringStart] = '"' then
|
||||
// Regular string
|
||||
parseRegularString searchText stringStart
|
||||
|> Option.map (fun (content, endPos) -> (content, stringStart, endPos))
|
||||
else
|
||||
None
|
||||
|
||||
parseResult
|
||||
|> Option.map (fun (content, stringStartPos, stringEndPos) ->
|
||||
let startPos = positionToLineColumn searchText stringStartPos
|
||||
let endPos = positionToLineColumn searchText stringEndPos
|
||||
|
||||
{
|
||||
StartLine = startIdx + startPos.Line + 1
|
||||
StartColumn = startPos.Column
|
||||
EndLine = startIdx + endPos.Line + 1
|
||||
EndColumn = endPos.Column
|
||||
Content = content
|
||||
}
|
||||
)
|
||||
)
|
||||
|
||||
/// Update the snapshot string with a new value; this doesn't edit the file on disk, but
|
||||
/// instead returns the new contents.
|
||||
/// We always write single-quoted @-strings for simplicity.
|
||||
let private updateSnapshot (lines : string[]) (info : StringLiteralInfo) (newContent : string) : string[] =
|
||||
let newString = "@\"" + newContent.Replace ("\"", "\"\"") + "\""
|
||||
|
||||
if info.StartLine = info.EndLine then
|
||||
// Single line update
|
||||
lines
|
||||
|> Array.mapi (fun i line ->
|
||||
if i = info.StartLine - 1 then
|
||||
let before = line.Substring (0, info.StartColumn)
|
||||
let after = line.Substring info.EndColumn
|
||||
before + newString + after
|
||||
else
|
||||
line
|
||||
)
|
||||
else
|
||||
// Multi-line update
|
||||
let startLineIdx = info.StartLine - 1
|
||||
let endLineIdx = info.EndLine - 1
|
||||
|
||||
let before = lines.[startLineIdx].Substring (0, info.StartColumn)
|
||||
let after = lines.[endLineIdx].Substring info.EndColumn
|
||||
|
||||
let newLines =
|
||||
if newContent.IndexOf '\n' >= 0 then
|
||||
let split = newContent.Replace("\"", "\"\"").Split ('\n')
|
||||
|
||||
match split with
|
||||
| [||] -> failwith "expected contents from split string"
|
||||
| [| single |] -> [| before + "@\"" + single + "\"" + after |]
|
||||
| [| first ; last |] -> [| before + "@\"" + first ; last + "\"" + after |]
|
||||
| split ->
|
||||
|
||||
[|
|
||||
yield before + "@\"" + split.[0]
|
||||
yield! split.[1 .. split.Length - 2]
|
||||
yield split.[split.Length - 1] + "\"" + after
|
||||
|]
|
||||
else
|
||||
// Convert to single-line verbatim string
|
||||
[| before + "@\"" + newContent.Replace ("\"", "\"\"") + "\"" + after |]
|
||||
|
||||
[|
|
||||
yield! lines |> Array.take startLineIdx
|
||||
yield! newLines
|
||||
yield! lines |> Array.skip (endLineIdx + 1)
|
||||
|]
|
||||
|
||||
/// <remarks>Example usage:
|
||||
/// <c>updateSnapshotAtLine [|lines-of-file|] 42 "new test output"</c>
|
||||
///
|
||||
/// This will find a snapshot call on line 42 like:
|
||||
/// snapshot "old value" -> snapshot @"new test output"
|
||||
/// snapshot @"old value" -> snapshot @"new test output"
|
||||
/// snapshot """old value""" -> snapshot @"new test output"
|
||||
/// snapshot """multi
|
||||
/// line""" -> snapshot """multi
|
||||
/// line"""
|
||||
/// snapshot "has \"\"\" in it" -> snapshot @"has """""" in it"
|
||||
/// </remarks>
|
||||
let updateSnapshotAtLine (fileLines : string[]) (snapshotLine : int) (newValue : string) : string[] =
|
||||
match findSnapshotString fileLines snapshotLine with
|
||||
| Some info ->
|
||||
Console.Error.WriteLine ("String literal to update: " + string<StringLiteralInfo> info)
|
||||
updateSnapshot fileLines info newValue
|
||||
| None -> failwithf "Could not find string literal after snapshot at line %d" snapshotLine
|
@@ -1,9 +1,13 @@
|
||||
WoofWare.Expect.Builder inherit obj
|
||||
WoofWare.Expect.Builder.expect [static property]: [read-only] WoofWare.Expect.ExpectBuilder
|
||||
WoofWare.Expect.Builder.expect' [static property]: [read-only] WoofWare.Expect.ExpectBuilder
|
||||
WoofWare.Expect.Builder.expectWithMockedFilePath [static method]: (string, int) -> WoofWare.Expect.ExpectBuilder
|
||||
WoofWare.Expect.Builder.get_expect [static method]: unit -> WoofWare.Expect.ExpectBuilder
|
||||
WoofWare.Expect.Builder.get_expect' [static method]: unit -> WoofWare.Expect.ExpectBuilder
|
||||
WoofWare.Expect.ExpectBuilder inherit obj
|
||||
WoofWare.Expect.ExpectBuilder..ctor [constructor]: (string * int) option
|
||||
WoofWare.Expect.ExpectBuilder..ctor [constructor]: (string * int)
|
||||
WoofWare.Expect.ExpectBuilder..ctor [constructor]: bool
|
||||
WoofWare.Expect.ExpectBuilder..ctor [constructor]: WoofWare.Expect.Mode
|
||||
WoofWare.Expect.ExpectBuilder.Bind [method]: (WoofWare.Expect.YouHaveSuppliedMultipleSnapshots WoofWare.Expect.ExpectState, unit -> 'U WoofWare.Expect.ExpectState) -> 'U WoofWare.Expect.ExpectState
|
||||
WoofWare.Expect.ExpectBuilder.Delay [method]: (unit -> 'T WoofWare.Expect.ExpectState) -> (unit -> 'T WoofWare.Expect.ExpectState)
|
||||
WoofWare.Expect.ExpectBuilder.Return [method]: 'T -> 'T WoofWare.Expect.ExpectState
|
||||
@@ -19,5 +23,7 @@ WoofWare.Expect.ExpectException.Equals [method]: System.Exception -> bool
|
||||
WoofWare.Expect.ExpectException.Message [property]: [read-only] string
|
||||
WoofWare.Expect.ExpectState`1 inherit obj, implements 'T WoofWare.Expect.ExpectState System.IEquatable, System.Collections.IStructuralEquatable, 'T WoofWare.Expect.ExpectState System.IComparable, System.IComparable, System.Collections.IStructuralComparable
|
||||
WoofWare.Expect.ExpectState`1.Equals [method]: ('T WoofWare.Expect.ExpectState, System.Collections.IEqualityComparer) -> bool
|
||||
WoofWare.Expect.Mode inherit obj, implements WoofWare.Expect.Mode System.IEquatable, System.Collections.IStructuralEquatable, WoofWare.Expect.Mode System.IComparable, System.IComparable, System.Collections.IStructuralComparable
|
||||
WoofWare.Expect.Mode.Equals [method]: (WoofWare.Expect.Mode, System.Collections.IEqualityComparer) -> bool
|
||||
WoofWare.Expect.YouHaveSuppliedMultipleSnapshots inherit obj, implements WoofWare.Expect.YouHaveSuppliedMultipleSnapshots System.IEquatable, System.Collections.IStructuralEquatable, WoofWare.Expect.YouHaveSuppliedMultipleSnapshots System.IComparable, System.IComparable, System.Collections.IStructuralComparable
|
||||
WoofWare.Expect.YouHaveSuppliedMultipleSnapshots.Equals [method]: (WoofWare.Expect.YouHaveSuppliedMultipleSnapshots, System.Collections.IEqualityComparer) -> bool
|
@@ -16,7 +16,9 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<Compile Include="Library.fs"/>
|
||||
<Compile Include="AssemblyInfo.fs" />
|
||||
<Compile Include="SnapshotUpdate.fs" />
|
||||
<Compile Include="Builder.fs" />
|
||||
<None Include="..\README.md">
|
||||
<Pack>True</Pack>
|
||||
<PackagePath>\</PackagePath>
|
||||
|
@@ -1,5 +1,5 @@
|
||||
{
|
||||
"version": "0.1",
|
||||
"version": "0.2",
|
||||
"publicReleaseRefSpec": [
|
||||
"^refs/heads/main$"
|
||||
],
|
||||
@@ -9,4 +9,4 @@
|
||||
":/Directory.Build.props",
|
||||
":/LICENSE"
|
||||
]
|
||||
}
|
||||
}
|
16
analyzers/analyzers.fsproj
Normal file
16
analyzers/analyzers.fsproj
Normal file
@@ -0,0 +1,16 @@
|
||||
<Project Sdk="Microsoft.Build.NoTargets/1.0.80"> <!-- This is not a project we want to build. -->
|
||||
|
||||
<PropertyGroup>
|
||||
<IsPackable>false</IsPackable>
|
||||
<IsPublishable>false</IsPublishable>
|
||||
<RestorePackagesPath>../.analyzerpackages/</RestorePackagesPath>
|
||||
<TargetFramework>net6.0</TargetFramework>
|
||||
<DisableImplicitNuGetFallbackFolder>true</DisableImplicitNuGetFallbackFolder>
|
||||
<AutomaticallyUseReferenceAssemblyPackages>false</AutomaticallyUseReferenceAssemblyPackages> <!-- We don't want to build this project, so we do not need the reference assemblies for the framework we chose.-->
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageDownload Include="G-Research.FSharp.Analyzers" Version="[0.15.0]" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
@@ -29,6 +29,11 @@
|
||||
"version": "1.4.36",
|
||||
"hash": "sha256-zZEhjP0mdc5E3fBPS4/lqD7sxoaoT5SOspP546RWYdc="
|
||||
},
|
||||
{
|
||||
"pname": "FsUnit",
|
||||
"version": "7.0.1",
|
||||
"hash": "sha256-K85CIdxMeFSHEKZk6heIXp/oFjWAn7dBILKrw49pJUY="
|
||||
},
|
||||
{
|
||||
"pname": "Microsoft.ApplicationInsights",
|
||||
"version": "2.22.0",
|
||||
|
Reference in New Issue
Block a user