Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Set up end to end Send/Receive testing with LexBox #342

Merged
merged 73 commits into from
Aug 26, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
73 commits
Select commit Hold shift + click to select a range
7a4873e
Add MkFwData and SplitFwData commands to LfMerge
rmunn Jul 25, 2024
ab49c3b
Start working on test environment for E2E tests
rmunn Jul 25, 2024
96f5d39
Better auth handling for E2E tests
rmunn Jul 25, 2024
5dd0a60
Add methods for resetting projects in LexBox
rmunn Jul 25, 2024
05a8c93
Add a basic unit test to exercise project reset
rmunn Jul 25, 2024
554bd2e
Add helper method to clone Lcm project from LexBox
rmunn Jul 30, 2024
4d5d4c9
Add GetEntries helper method
rmunn Jul 30, 2024
41c58ed
Deal with disposable FwProjects better
rmunn Jul 30, 2024
83d6e21
Add helper method to get a single entry
rmunn Jul 30, 2024
2b4e84a
Add helper for commit and push
rmunn Jul 30, 2024
3a54e81
Improve LcmTestHelper method for setting text
rmunn Jul 31, 2024
f28c6ef
Add test to ensure commit is working
rmunn Jul 31, 2024
d2665f5
Start of new SRTestBase class which will autoreset
rmunn Aug 7, 2024
f273aa6
Efficiency: don't rollback if already at right rev
rmunn Aug 7, 2024
4b844e9
SRTestBase sets up a test environment for itself
rmunn Aug 7, 2024
6f93dec
Simpler way to turn test names into filenames
rmunn Aug 7, 2024
fd82dfc
Remove overload of SRTestEnvironment constructor
rmunn Aug 8, 2024
9d15c3b
Much better setup and teardown in SRTestBase
rmunn Aug 8, 2024
8399906
Clone, commit, and push tests are now repeatable
rmunn Aug 8, 2024
37fb990
Fix the CommitChanges helper function
rmunn Aug 8, 2024
7009555
Fix Mercurial commits committing too much
rmunn Aug 8, 2024
1bb8789
Make CallLfMergeBridge public so tests can use it
rmunn Aug 9, 2024
c9e6854
Add basic (rudimentary, even) test for comments
rmunn Aug 9, 2024
fa873e2
Refactor, moving HTTP stuff out of LcmTestHelper
rmunn Aug 13, 2024
5055ee2
Add more-efficient entry count method
rmunn Aug 13, 2024
78bc1f6
Refactor SRTestEnv to keep track of URL parts
rmunn Aug 13, 2024
6c857ab
Add SRTestEnv method for project URLs with auth
rmunn Aug 13, 2024
213a7df
Fix reset-project method failing on existing files
rmunn Aug 13, 2024
7dfef75
Add ability to make GraphQL queries to LexBox
rmunn Aug 13, 2024
3c90d12
Add other project types even if we won't use them
rmunn Aug 13, 2024
43c02e7
Ensure sena-3.zip is available to all tests
rmunn Aug 13, 2024
b0c2fe9
Move CreateProject into SRTestBase
rmunn Aug 13, 2024
99e7384
Move TestTearDown to be next to TestSetUp
rmunn Aug 13, 2024
9bd0a09
Auto-delete created projects on test success
rmunn Aug 13, 2024
bf52e49
Slight GraphQL improvement
rmunn Aug 13, 2024
238049a
Attempt to debug teardown race condition
rmunn Aug 13, 2024
ef5f480
Remove debug logging since race condition is fixed
rmunn Aug 14, 2024
a18650e
Derive SRTestEnv from TestEnv class
rmunn Aug 14, 2024
221d6c3
Set env var so FW will use temp folder for storage
rmunn Aug 14, 2024
06ca00c
Add beginnings of first "real" end-to-end test
rmunn Aug 14, 2024
6af7169
Use static properties for SRTestEnv.HttpClient
rmunn Aug 15, 2024
adcd8f2
Make most SRTestEnv methods static
rmunn Aug 15, 2024
985683d
Less spammy logs from TransferLcmToMongo in tests
rmunn Aug 15, 2024
70e34e8
Create helper method for E2E LF project creation
rmunn Aug 15, 2024
3fa3660
Very slight performance improvement
rmunn Aug 15, 2024
01364ce
Clone LF and FW projects with different codes
rmunn Aug 19, 2024
5689335
Working end-to-end Send/Receive test
rmunn Aug 19, 2024
b0158a4
Improve LcmTestHelper API
rmunn Aug 19, 2024
ab3c21e
Refactor common code, greatly simplifying E2E test
rmunn Aug 19, 2024
f737703
Final refactoring of first E2E test
rmunn Aug 19, 2024
aaf4f36
Clean up now-unused code in E2ETestBase
rmunn Aug 19, 2024
16d03cd
Minor code alignment tweak
rmunn Aug 19, 2024
38fce52
Remove MkFwData code; we don't need it after all
rmunn Aug 19, 2024
e48052e
Delete more now-unused S/R test code
rmunn Aug 19, 2024
6d99535
Fix creating empty FLEx projects in LexBox
rmunn Aug 19, 2024
8db4721
Remove unused using statements
rmunn Aug 19, 2024
779358e
Demonstrate another hgweb race condition
rmunn Aug 19, 2024
80d45bb
Make CloneRepoFromLexbox resilient to temp 404s
rmunn Aug 19, 2024
3749cbe
Make most SRTestEnv methods non-static again
rmunn Aug 21, 2024
bea1713
Allow overriding repo URL in settings, not env vars
rmunn Aug 21, 2024
946468d
Don't hardcode GUID in LexBox tests
rmunn Aug 21, 2024
5c7059b
Consolidate E2E base classes into one base class
rmunn Aug 21, 2024
129aaa2
Ignore E2E tests if LexBox isn't available
rmunn Aug 21, 2024
a5b5449
Don't throw exceptions if LexBox is unavailable
rmunn Aug 21, 2024
0af5fb2
Refactor common assertions about project creation
rmunn Aug 22, 2024
b3e258d
Replace unmaintained TUS library with newer one
rmunn Aug 22, 2024
5fe1d6d
Dispose SRTestEnv during test TearDown method
rmunn Aug 22, 2024
52017e9
Don't use TempFolder if we don't need auto-cleanup
rmunn Aug 22, 2024
1eea903
Better name for cloning FW project from Lexbox
rmunn Aug 22, 2024
0d1e52f
Handle needing to delete multiple LexBox projects
rmunn Aug 22, 2024
6844012
Remove now-unused properties
rmunn Aug 26, 2024
0f10f6b
Dispose of FileStream after TUS upload
rmunn Aug 26, 2024
9f09fc0
Rename TryOutE2ETests to LexboxSendReceiveTests
rmunn Aug 26, 2024
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
234 changes: 234 additions & 0 deletions src/LfMerge.Core.Tests/E2E/E2ETestBase.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,234 @@
using System;
using System.Collections.Generic;
using System.IO;
using System.Threading.Tasks;
using Autofac;
using LfMerge.Core.Actions;
using LfMerge.Core.FieldWorks;
using LfMerge.Core.MongoConnector;
using NUnit.Framework;
using NUnit.Framework.Interfaces;
using SIL.LCModel;
using SIL.TestUtilities;

namespace LfMerge.Core.Tests.E2E
{
[TestFixture]
[Category("LongRunning")]
[Category("IntegrationTests")]
public class E2ETestBase
{
public LfMerge.Core.Logging.ILogger Logger => MainClass.Logger;
public TemporaryFolder TempFolderForClass { get; set; }
public TemporaryFolder TempFolderForTest { get; set; }
public string Sena3ZipPath { get; set; }
private readonly HashSet<Guid> ProjectIdsToDelete = [];
public SRTestEnvironment TestEnv { get; set; }

public MongoConnectionDouble _mongoConnection;
public MongoProjectRecordFactory _recordFactory;

public E2ETestBase()
{
}

private static string TestName => TestContext.CurrentContext.Test.Name;
private static string TestNameForPath => string.Concat(TestName.Split(Path.GetInvalidPathChars())); // Easiest way to strip out all invalid chars

[OneTimeSetUp]
public async Task FixtureSetup()
{
// Ensure top-level /tmp/LfMergeSRTests folder and subfolders exist
var tempPath = Path.Join(Path.GetTempPath(), "LfMergeSRTests");
Directory.CreateDirectory(tempPath);
var testDataPath = Path.Join(tempPath, "data");
Directory.CreateDirectory(testDataPath);
var lcmDataPath = Path.Join(tempPath, "lcm-common");
Directory.CreateDirectory(lcmDataPath);
Environment.SetEnvironmentVariable("FW_CommonAppData", lcmDataPath);

var derivedClassName = this.GetType().Name;
TempFolderForClass = new TemporaryFolder(Path.Join(tempPath, derivedClassName));

// Ensure sena-3.zip is available to all tests as a starting point
Sena3ZipPath = Path.Join(testDataPath, "sena-3.zip");
if (!File.Exists(Sena3ZipPath)) {
using var testEnv = new SRTestEnvironment(TempFolderForTest);
if (!await testEnv.IsLexBoxAvailable()) {
Assert.Ignore("Can't run E2E tests without a copy of LexBox to test against. Please either launch LexBox on localhost port 80, or set the appropriate environment variables to point to a running copy of LexBox.");
}
await testEnv.Login();
await testEnv.DownloadProjectBackup("sena-3", Sena3ZipPath);
}
}

[OneTimeTearDown]
public void FixtureTeardown()
{
Environment.SetEnvironmentVariable("FW_CommonAppData", null);
var result = TestContext.CurrentContext.Result;
var nonSuccess = result.FailCount + result.InconclusiveCount + result.WarningCount;
// Only delete class temp folder if we passed or skipped all tests
if (nonSuccess == 0) TempFolderForClass.Dispose();
hahn-kev marked this conversation as resolved.
Show resolved Hide resolved
}

[SetUp]
public async Task TestSetup()
{
TempFolderForTest = new TemporaryFolder(TempFolderForClass, TestNameForPath);
TestEnv = new SRTestEnvironment(TempFolderForTest);
if (!await TestEnv.IsLexBoxAvailable()) {
Assert.Ignore("Can't run E2E tests without a copy of LexBox to test against. Please either launch LexBox on localhost port 80, or set the appropriate environment variables to point to a running copy of LexBox.");
}
await TestEnv.Login();

MagicStrings.SetMinimalModelVersion(LcmCache.ModelVersion);
_mongoConnection = MainClass.Container.Resolve<IMongoConnection>() as MongoConnectionDouble;
if (_mongoConnection == null)
throw new AssertionException("E2E tests need a mock MongoConnection that stores data in order to work.");
_recordFactory = MainClass.Container.Resolve<MongoProjectRecordFactory>() as MongoProjectRecordFactoryDouble;
if (_recordFactory == null)
throw new AssertionException("E2E tests need a mock MongoProjectRecordFactory in order to work.");
}

[TearDown]
public async Task TestTeardown()
hahn-kev marked this conversation as resolved.
Show resolved Hide resolved
{
var outcome = TestContext.CurrentContext.Result.Outcome;
var success = outcome == ResultState.Success || outcome == ResultState.Ignored;
// Only delete temp folder if test passed, otherwise we'll want to leave it in place for post-test investigation
TestEnv.DeleteTempFolderDuringCleanup = success;
// On failure, also leave LexBox project(s) in place for post-test investigation, even though this might tend to clutter things up a little
if (success) {
foreach (var projId in ProjectIdsToDelete) {
await TestEnv.DeleteLexBoxProject(projId);
}
}
ProjectIdsToDelete.Clear();
TestEnv.Dispose();
}

public string TestFolderForProject(string projectCode) => Path.Join(TempFolderForTest.Path, "webwork", projectCode);
public string FwDataPathForProject(string projectCode) => Path.Join(TestFolderForProject(projectCode), $"{projectCode}.fwdata");

public string CloneRepoFromLexbox(string code, string? newCode = null, TimeSpan? waitTime = null)
{
var projUrl = SRTestEnvironment.LexboxUrlForProjectWithAuth(code);
newCode ??= code;
var dest = TestFolderForProject(newCode);
if (waitTime is null) {
MercurialTestHelper.CloneRepo(projUrl.AbsoluteUri, dest);
} else {
var start = DateTime.UtcNow;
var success = false;
while (!success) {
try {
MercurialTestHelper.CloneRepo(projUrl.AbsoluteUri, dest);
} catch {
if (DateTime.UtcNow > start + waitTime) {
throw; // Give up
}
System.Threading.Thread.Sleep(250);
continue;
}
// If we got this far, no exception so we succeeded
success = true;
}
}
return dest;
}

public FwProject CloneFwProjectFromLexbox(string code, string? newCode = null, TimeSpan? waitTime = null)
{
var dest = CloneRepoFromLexbox(code, newCode, waitTime);
var dirInfo = new DirectoryInfo(dest);
if (!dirInfo.Exists) throw new InvalidOperationException($"Failed to clone {code} from lexbox, cannot create FwProject");
var dirname = dirInfo.Name;
var fwdataPath = Path.Join(dest, $"{dirname}.fwdata");
MercurialTestHelper.ChangeBranch(dest, "tip");
LfMergeBridge.LfMergeBridge.ReassembleFwdataFile(TestEnv.NullProgress, false, fwdataPath);
var settings = new LfMergeSettingsDouble(TempFolderForTest.Path);
return new FwProject(settings, dirname);
}

public async Task<string> CreateEmptyFlexProjectInLexbox()
{
var randomGuid = Guid.NewGuid();
var testCode = $"sr-{randomGuid}";
var testPath = TestFolderForProject(testCode);
MercurialTestHelper.InitializeHgRepo(testPath);
MercurialTestHelper.CreateFlexRepo(testPath);
// Now create project in LexBox
var result = await TestEnv.CreateLexBoxProject(testCode, randomGuid);
var pushUrl = SRTestEnvironment.LexboxUrlForProjectWithAuth(testCode).AbsoluteUri;
MercurialTestHelper.HgPush(testPath, pushUrl);
if (result.Id.HasValue) ProjectIdsToDelete.Add(result.Id.Value);
return testCode;
}

public async Task<string> CreateNewProjectFromTemplate(string origZipPath)
{
var randomGuid = Guid.NewGuid();
var testCode = $"sr-{randomGuid}";
var testPath = TestFolderForProject(testCode);
// Now create project in LexBox
var result = await TestEnv.CreateLexBoxProject(testCode, randomGuid);
await TestEnv.ResetAndUploadZip(testCode, origZipPath);
if (result.Id.HasValue) ProjectIdsToDelete.Add(result.Id.Value);
return testCode;
}

public Task<string> CreateNewProjectFromSena3() => CreateNewProjectFromTemplate(Sena3ZipPath);

public void CommitAndPush(FwProject project, string code, string? localCode = null, string? commitMsg = null)
{
TestEnv.CommitAndPush(project, code, TempFolderForTest.Path, localCode, commitMsg);
}

public async Task<LanguageForgeProject> CreateLfProjectFromSena3()
{
var projCode = await CreateNewProjectFromSena3();
var projPath = CloneRepoFromLexbox(projCode, waitTime:TimeSpan.FromSeconds(5));
MercurialTestHelper.ChangeBranch(projPath, "tip");
var fwdataPath = Path.Join(projPath, $"{projCode}.fwdata");
LfMergeBridge.LfMergeBridge.ReassembleFwdataFile(TestEnv.NullProgress, false, fwdataPath);

// Do an initial clone from LexBox to the mock LF
var lfProject = LanguageForgeProject.Create(projCode, TestEnv.Settings);
lfProject.IsInitialClone = true;
var transferLcmToMongo = new TransferLcmToMongoAction(TestEnv.Settings, TestEnv.NullLogger, _mongoConnection, _recordFactory);
transferLcmToMongo.Run(lfProject);

return lfProject;
}

public void SendReceiveToLexbox(LanguageForgeProject lfProject)
{
TestEnv.Settings.LanguageDepotRepoUri = SRTestEnvironment.LexboxUrlForProjectWithAuth(lfProject.ProjectCode).AbsoluteUri;
var syncAction = new SynchronizeAction(TestEnv.Settings, TestEnv.Logger);
syncAction.Run(lfProject);
}

public (string, DateTime, DateTime) UpdateFwGloss(FwProject project, Guid entryId, Func<string, string> textConverter)
{
var fwEntry = LcmTestHelper.GetEntry(project, entryId);
Assert.That(fwEntry, Is.Not.Null);
var origModifiedDate = fwEntry.DateModified;
var unchangedGloss = LcmTestHelper.UpdateAnalysisText(project, fwEntry.SensesOS[0].Gloss, textConverter);
return (unchangedGloss, origModifiedDate, fwEntry.DateModified);
}

public (string, DateTime, DateTime) UpdateLfGloss(LanguageForgeProject lfProject, Guid entryId, string wsId, Func<string, string> textConverter)
{
var lfEntry = _mongoConnection.GetLfLexEntryByGuid(entryId);
Assert.That(lfEntry, Is.Not.Null);
var unchangedGloss = lfEntry.Senses[0].Gloss[wsId].Value;
lfEntry.Senses[0].Gloss["pt"].Value = textConverter(unchangedGloss);
// Remember that in LfMerge it's AuthorInfo that corresponds to the Lcm modified date
DateTime origModifiedDate = lfEntry.AuthorInfo.ModifiedDate;
lfEntry.AuthorInfo.ModifiedDate = DateTime.UtcNow;
_mongoConnection.UpdateRecord(lfProject, lfEntry);
return (unchangedGloss, origModifiedDate, lfEntry.AuthorInfo.ModifiedDate);
}
}
}
61 changes: 61 additions & 0 deletions src/LfMerge.Core.Tests/E2E/LexboxSendReceiveTests.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
using System;
using System.Threading.Tasks;
using NUnit.Framework;
using System.Text.RegularExpressions;

namespace LfMerge.Core.Tests.E2E
{
[TestFixture]
[Category("LongRunning")]
[Category("IntegrationTests")]
public class LexboxSendReceiveTests : E2ETestBase
{
// This test will often trigger a race condition in LexBox that causes the *next* test to fail
// when `hg clone` returns 404. This is because of hgweb's directory cache, which only refreshes
// if it hasn't been refreshed more than N seconds ago (default 20, LexBox currently uses 5).
// Which means that even though CreateLfProjectFromSena3 has created the LexBox project, LexBox's
// copy of hgweb doesn't see it yet so it can't be cloned.
//
// The solution will be to adjust CloneRepoFromLexbox to take a parameter that is the number of
// seconds to wait for the project to become visible, and retry 404's until that much time has elapsed.
// Then only throw an exception if hgweb is still returning 404 after its dir cache should be refreshed.
[Test]
public async Task E2E_CheckFwProjectCreation()
{
var code = await CreateEmptyFlexProjectInLexbox();
Console.WriteLine($"Created new project {code}");
}

[Test]
public async Task E2E_LFDataChangedLDDataChanged_LFWins()
{
// Setup

var lfProject = await CreateLfProjectFromSena3();
var fwProjectCode = Regex.Replace(lfProject.ProjectCode, "^sr-", "fw-");
var fwProject = CloneFwProjectFromLexbox(lfProject.ProjectCode, fwProjectCode);

// Modify FW data first, then push to Lexbox
Guid entryId = LcmTestHelper.GetFirstEntry(fwProject).Guid;
var (unchangedGloss, origFwDateModified, fwDateModified) = UpdateFwGloss(fwProject, entryId, text => text + " - changed in FW");
CommitAndPush(fwProject, lfProject.ProjectCode, fwProjectCode, "Modified gloss in FW");

// Modify LF data second
var (_, origLfDateModified, _) = UpdateLfGloss(lfProject, entryId, "pt", text => text + " - changed in LF");

// Exercise

SendReceiveToLexbox(lfProject);

// Verify

// LF side should win conflict since its modified date was later
var lfEntryAfterSR = _mongoConnection.GetLfLexEntryByGuid(entryId);
Assert.That(lfEntryAfterSR?.Senses?[0]?.Gloss?["pt"]?.Value, Is.EqualTo(unchangedGloss + " - changed in LF"));
// LF's modified dates should have been updated by the sync action
Assert.That(lfEntryAfterSR.AuthorInfo.ModifiedDate, Is.GreaterThan(origLfDateModified));
// Remember that FieldWorks's DateModified is stored in local time for some incomprehensible reason...
Assert.That(lfEntryAfterSR.AuthorInfo.ModifiedDate, Is.GreaterThan(fwDateModified.ToUniversalTime()));
}
}
}
83 changes: 83 additions & 0 deletions src/LfMerge.Core.Tests/LcmTestHelper.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
using System;
using System.Collections.Generic;
using System.Linq;
using LfMerge.Core.FieldWorks;
using SIL.LCModel;
using SIL.LCModel.Infrastructure;

namespace LfMerge.Core.Tests
{
public static class LcmTestHelper
{
public static IEnumerable<ILexEntry> GetEntries(FwProject project)
{
return project?.ServiceLocator?.LanguageProject?.LexDbOA?.Entries ?? [];
}

public static int CountEntries(FwProject project)
{
var repo = project?.ServiceLocator?.GetInstance<ILexEntryRepository>();
return repo.Count;
}

public static ILexEntry GetEntry(FwProject project, Guid guid)
{
var repo = project?.ServiceLocator?.GetInstance<ILexEntryRepository>();
return repo.GetObject(guid);
}

public static ILexEntry GetFirstEntry(FwProject project)
{
var repo = project?.ServiceLocator?.GetInstance<ILexEntryRepository>();
return repo.AllInstances().First();
}

public static string? GetVernacularText(IMultiUnicode field)
{
return field.BestVernacularAlternative?.Text;
}

public static string? GetAnalysisText(IMultiUnicode field)
{
return field.BestAnalysisAlternative?.Text;
}

public static void SetVernacularText(FwProject project, IMultiUnicode field, string newText)
{
var accessor = project.Cache.ActionHandlerAccessor;
UndoableUnitOfWorkHelper.DoUsingNewOrCurrentUOW("undo", "redo", accessor, () => {
field.SetVernacularDefaultWritingSystem(newText);
});
}

public static void SetAnalysisText(FwProject project, IMultiUnicode field, string newText)
{
var accessor = project.Cache.ActionHandlerAccessor;
UndoableUnitOfWorkHelper.DoUsingNewOrCurrentUOW("undo", "redo", accessor, () => {
field.SetAnalysisDefaultWritingSystem(newText);
});
}

public static string UpdateVernacularText(FwProject project, IMultiUnicode field, Func<string, string> textConverter)
{
var oldText = field.BestVernacularAlternative?.Text;
if (oldText != null)
{
var newText = textConverter(oldText);
SetVernacularText(project, field, newText);
}
return oldText;
}

public static string UpdateAnalysisText(FwProject project, IMultiUnicode field, Func<string, string> textConverter)
{
var oldText = field.BestAnalysisAlternative?.Text;
if (oldText != null)
{
var newText = textConverter(oldText);
SetAnalysisText(project, field, newText);
}
return oldText;
}
}
}
Loading
Loading