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

Try to fix reset-project Playwright tests #789

Merged
merged 33 commits into from
May 30, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
33 commits
Select commit Hold shift + click to select a range
3eaf90e
Try to fix reset-project Playwright tests
rmunn May 7, 2024
907c724
Use `ls` rather than Directory.GetFiles
rmunn May 7, 2024
4805341
Fail reset-project test fast if NFS error persists
rmunn May 7, 2024
343f236
Move NFS-cache busting to where it's really needed
rmunn May 8, 2024
b544164
Make resetProject E2E test pass consistently
rmunn May 8, 2024
5b44a96
Create new invalidatedircache command in runner
rmunn May 8, 2024
93da2ff
Get rid of now-unneeded waits in integration tests
rmunn May 8, 2024
0276e00
Make one more integration test more reliable
rmunn May 8, 2024
00f8343
Run dir-cache invalidation in backend, not tests
rmunn May 9, 2024
ffb086a
Allow cancellation token to be null in invalidate cmd
rmunn May 9, 2024
b48802f
Revert "allow null CancellationToken"
rmunn May 9, 2024
1df5933
Temporarily disable two HgService tests
rmunn May 9, 2024
542182c
Invalidate dir cache in a couple more places
rmunn May 9, 2024
69baf48
Suppress expected error message when deleting repo
rmunn May 9, 2024
593dcb8
Try to await the result of invalidatedircache call
rmunn May 9, 2024
3f827e7
Attempt to gather more data
rmunn May 9, 2024
9060503
Try new approach to invalidating NFS cache
rmunn May 9, 2024
5743922
Pause bewteen retries in WaitForRepoEmptyState
rmunn May 10, 2024
62d40ca
Try to speed up project-reset E2E test
rmunn May 10, 2024
8c020b1
Longer delay in WaitForRepoEmptyState
rmunn May 10, 2024
a620a93
Try calling `sync` before WaitForRepoEmptyState
rmunn May 10, 2024
9c25907
Try invalidating NFS cache by doing a write
rmunn May 28, 2024
6f86d26
Merge branch 'develop' into chore/try-to-fix-playwright-reset-tests
rmunn May 28, 2024
c5feebe
Fix deleteProject logic
rmunn May 28, 2024
2fb2ad4
Detect situation where empty repo zip uploaded
rmunn May 28, 2024
c7967f6
Slightly safer InvalidateDirCache
rmunn May 28, 2024
c13c4a7
Remove one final TODO comment
rmunn May 28, 2024
8a55b61
Add proper mocking of HttpClient in HgServiceTests
rmunn May 29, 2024
2c7f2ef
Use cancellation tokens in new HgService code
rmunn May 29, 2024
a581c38
Remove leftover debugging code
rmunn May 29, 2024
18ae58e
Ensure WaitForRepoEmptyState doesn't throw on timeout
rmunn May 30, 2024
8ac1ba4
CancellationTokenSource is IDisposable, so dispose it
rmunn May 30, 2024
6c2b514
Merge branch 'develop' into chore/try-to-fix-playwright-reset-tests
rmunn May 30, 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
69 changes: 69 additions & 0 deletions backend/LexBoxApi/Services/HgService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@
private const string DELETED_REPO_FOLDER = "_____deleted_____";
private const string TEMP_REPO_FOLDER = "_____temp_____";

private const string AllZeroHash = "0000000000000000000000000000000000000000";

private readonly IOptions<HgConfig> _options;
private readonly Lazy<HttpClient> _hgClient;
private readonly ILogger<HgService> _logger;
Expand Down Expand Up @@ -67,6 +69,8 @@
{
InitRepoAt(new DirectoryInfo(PrefixRepoFilePath(code)));
});
await InvalidateDirCache(code);
await WaitForRepoEmptyState(code, RepoEmptyState.Empty);
}

private void InitRepoAt(DirectoryInfo repoDirectory)
Expand Down Expand Up @@ -104,6 +108,8 @@
await SoftDeleteRepo(code, $"{FileUtils.ToTimestamp(DateTimeOffset.UtcNow)}__reset");
//we must init the repo as uploading a zip is optional
tmpRepo.MoveTo(PrefixRepoFilePath(code));
await InvalidateDirCache(code);
await WaitForRepoEmptyState(code, RepoEmptyState.Empty);
}

public async Task FinishReset(string code, Stream zipFile)
Expand Down Expand Up @@ -137,6 +143,11 @@
// Now we're ready to move the new repo into place, replacing the old one
await DeleteRepo(code);
tempRepo.MoveTo(PrefixRepoFilePath(code));
await InvalidateDirCache(code);
// If someone uploaded an *empty* repo, we don't want to wait forever for a non-empty state
var changelogPath = Path.Join(PrefixRepoFilePath(code), ".hg", "store", "00changelog.i");
var expectedState = File.Exists(changelogPath) ? RepoEmptyState.NonEmpty : RepoEmptyState.Empty;
hahn-kev marked this conversation as resolved.
Dismissed
Show resolved Hide resolved
await WaitForRepoEmptyState(code, expectedState);
}

/// <summary>
Expand Down Expand Up @@ -262,6 +273,58 @@
return response;
}

public Task<HttpContent> InvalidateDirCache(string code, CancellationToken token = default)
{
var repoPath = Path.Join(PrefixRepoFilePath(code));
if (Directory.Exists(repoPath))
{
// Invalidate NFS directory cache by forcing a write and re-read of the repo directory
var randomPath = Path.Join(repoPath, Path.GetRandomFileName());
while (File.Exists(randomPath) || Directory.Exists(randomPath)) { randomPath = Path.Join(repoPath, Path.GetRandomFileName()); }
try
{
// Create and delete a directory since that's slightly safer than a file
var d = Directory.CreateDirectory(randomPath);
d.Delete();
}
catch (Exception) { }
}
var result = ExecuteHgCommandServerCommand(code, "invalidatedircache", token);
return result;
}

public async Task<string> GetTipHash(string code, CancellationToken token = default)
{
var content = await ExecuteHgCommandServerCommand(code, "tip", token);
return await content.ReadAsStringAsync();
}

private async Task WaitForRepoEmptyState(string code, RepoEmptyState expectedState, int timeoutMs = 30_000, CancellationToken token = default)
{
// Set timeout so unforeseen errors can't cause an infinite loop
using var timeoutSource = CancellationTokenSource.CreateLinkedTokenSource(token);
timeoutSource.CancelAfter(timeoutMs);
var done = false;
try
{
while (!done && !timeoutSource.IsCancellationRequested)
{
var hash = await GetTipHash(code, timeoutSource.Token);
var isEmpty = hash == AllZeroHash;
done = expectedState switch
{
RepoEmptyState.Empty => isEmpty,
RepoEmptyState.NonEmpty => !isEmpty
};
if (!done) await Task.Delay(2500, timeoutSource.Token);
}
}
// We don't want to actually throw if we hit the timeout, because the operation *will* succeed eventually
// once the NFS caches synchronize, so we don't want to propagate an error message to the end user. So
// even if the timeout is hit, return as if we succeeded.
catch (OperationCanceledException) { }
}

public async Task<int?> GetLexEntryCount(string code, ProjectType projectType)
{
var command = projectType switch
Expand Down Expand Up @@ -408,3 +471,9 @@
{
public BrowseFilesResponse[]? Files { get; set; }
}

public enum RepoEmptyState
{
Empty,
NonEmpty
}
8 changes: 8 additions & 0 deletions backend/LexBoxApi/Services/ProjectService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -117,6 +117,10 @@ public async Task FinishReset(string code, Stream? zipFile = null)
await hgService.FinishReset(code, zipFile);
await UpdateProjectMetadata(project);
}
else
{
await hgService.InvalidateDirCache(code);
}
project.ResetStatus = ResetStatus.None;
project.UpdateUpdatedDate();
await dbContext.SaveChangesAsync();
Expand Down Expand Up @@ -146,6 +150,10 @@ public async Task UpdateProjectMetadata(Project project)
project.FlexProjectMetadata.LexEntryCount = count;
}
}
else
{
await hgService.InvalidateDirCache(project.Code);
}

project.LastCommit = await hgService.GetLastCommitTimeFromHg(project.Code);
// Caller is responsible for caling dbContext.SaveChangesAsync()
Expand Down
2 changes: 2 additions & 0 deletions backend/LexCore/ServiceInterfaces/IHgService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,9 +15,11 @@ public interface IHgService
Task ResetRepo(string code);
Task FinishReset(string code, Stream zipFile);
Task<HttpContent> VerifyRepo(string code, CancellationToken token);
Task<string> GetTipHash(string code, CancellationToken token = default);
Task<int?> GetLexEntryCount(string code, ProjectType projectType);
Task<string?> GetRepositoryIdentifier(Project project);
Task<HttpContent> ExecuteHgRecover(string code, CancellationToken token);
Task<HttpContent> InvalidateDirCache(string code, CancellationToken token = default);
bool HasAbandonedTransactions(string projectCode);
Task<string> HgCommandHealth();
}
11 changes: 10 additions & 1 deletion backend/Testing/LexCore/Services/HgServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
using Microsoft.Extensions.Logging.Abstractions;
using Microsoft.Extensions.Options;
using Moq;
using Moq.Contrib.HttpClient;
using Shouldly;
using Testing.Fixtures;

Expand All @@ -30,8 +31,16 @@ public HgServiceTests()
HgResumableUrl = LexboxResumable,
SendReceiveDomain = LexboxHgWeb
};
var handler = new Mock<HttpMessageHandler>(MockBehavior.Strict);

// This may need to become more sophisticated if our FinishReset tests are changed to include
// a Mercurial repo with actual commits in it, but this is good enough at the moment.
var AllZeroHash = "0000000000000000000000000000000000000000";
handler.SetupAnyRequest().ReturnsResponse(AllZeroHash);

var mockFactory = handler.CreateClientFactory();
_hgService = new HgService(new OptionsWrapper<HgConfig>(_hgConfig),
Mock.Of<IHttpClientFactory>(),
mockFactory,
NullLogger<HgService>.Instance);
CleanUpTempDir();
}
Expand Down
5 changes: 0 additions & 5 deletions backend/Testing/Services/Utils.cs
Original file line number Diff line number Diff line change
Expand Up @@ -84,11 +84,6 @@ public static async Task WaitForHgRefreshIntervalAsync()
await Task.Delay(TestingEnvironmentVariables.HgRefreshInterval);
}

public static async Task WaitForLexboxMetadataUpdateAsync()
{
await Task.Delay(3000);
}

private static string GetNewProjectDir(string projectCode,
[CallerMemberName] string projectName = "")
{
Expand Down
12 changes: 0 additions & 12 deletions backend/Testing/SyncReverseProxy/SendReceiveServiceTests.cs
Original file line number Diff line number Diff line change
Expand Up @@ -81,14 +81,10 @@ public async Task ModifyProjectData(HgProtocol protocol)
var projectConfig = _srFixture.InitLocalFlexProjectWithRepo();
await using var project = await RegisterProjectInLexBox(projectConfig, _adminApiTester);

await WaitForHgRefreshIntervalAsync();

// Push the project to the server
var sendReceiveParams = new SendReceiveParams(protocol, projectConfig);
_sendReceiveService.SendReceiveProject(sendReceiveParams, AdminAuth);

await WaitForLexboxMetadataUpdateAsync();

// Verify pushed and store last commit
var lastCommitDate = await _adminApiTester.GetProjectLastCommit(projectConfig.Code);
lastCommitDate.ShouldNotBeNullOrEmpty();
Expand All @@ -101,8 +97,6 @@ public async Task ModifyProjectData(HgProtocol protocol)
// Push changes
_sendReceiveService.SendReceiveProject(sendReceiveParams, AdminAuth, "Modify project data automated test");

await WaitForLexboxMetadataUpdateAsync();

// Verify the push updated the last commit date
var lastCommitDateAfter = await _adminApiTester.GetProjectLastCommit(projectConfig.Code);
lastCommitDateAfter.ShouldBeGreaterThan(lastCommitDate);
Expand All @@ -117,8 +111,6 @@ public async Task SendReceiveAfterProjectReset(HgProtocol protocol)
var projectConfig = _srFixture.InitLocalFlexProjectWithRepo(protocol, "SR_AfterReset");
await using var project = await RegisterProjectInLexBox(projectConfig, _adminApiTester);

await WaitForHgRefreshIntervalAsync();

var sendReceiveParams = new SendReceiveParams(protocol, projectConfig);
var srResult = _sendReceiveService.SendReceiveProject(sendReceiveParams, AdminAuth);

Expand All @@ -144,8 +136,6 @@ public async Task SendReceiveAfterProjectReset(HgProtocol protocol)
await _adminApiTester.HttpClient.PostAsync($"{_adminApiTester.BaseUrl}/api/project/resetProject/{projectConfig.Code}", null);
await _adminApiTester.HttpClient.PostAsync($"{_adminApiTester.BaseUrl}/api/project/finishResetProject/{projectConfig.Code}", null);

await WaitForHgRefreshIntervalAsync(); // TODO 765: Remove this

// Step 2: verify project is now empty, i.e. tip is "0000000..."
response = await _adminApiTester.HttpClient.GetAsync(tipUri.Uri);
jsonResult = await response.Content.ReadFromJsonAsync<JsonObject>();
Expand All @@ -169,8 +159,6 @@ public async Task SendReceiveAfterProjectReset(HgProtocol protocol)
var srResultStep3 = _sendReceiveService.SendReceiveProject(sendReceiveParams, AdminAuth);
_output.WriteLine(srResultStep3);

await WaitForHgRefreshIntervalAsync(); // TODO 765: Remove this

// Step 4: verify project tip is same hash as original project tip
response = await _adminApiTester.HttpClient.GetAsync(tipUri.Uri);
jsonResult = await response.Content.ReadFromJsonAsync<JsonObject>();
Expand Down
1 change: 1 addition & 0 deletions backend/Testing/Testing.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
<Choose>
<When Condition=" '$(MercurialVersion)' == '6' ">
<ItemGroup>
<PackageReference Include="Moq.Contrib.HttpClient" Version="1.4.0" />
<PackageReference Include="SIL.Chorus.LibChorus" Version="6.0.0-beta0048" />
<PackageReference Include="SIL.Chorus.Mercurial" Version="6.*" />
</ItemGroup>
Expand Down
48 changes: 16 additions & 32 deletions frontend/tests/resetProject.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,20 +37,14 @@ test('reset project and upload .zip file', async ({ page, tempProject, tempDir }
await resetProjectModel.assertGone();

// Step 2: Get tip hash and file list from hgweb, check some known values
// It can take a while for the server to pick up the new repo
let beforeResetJson: HgWebJson;
await expect(async () => {
const beforeResetResponse = await page.request.get(`${testEnv.serverBaseUrl}/hg/${tempProject.code}/file/tip?style=json-lex`);
beforeResetJson = await beforeResetResponse.json() as HgWebJson;
expect(beforeResetJson).toHaveProperty('node');
expect(beforeResetJson.node).not.toEqual(allZeroHash);
expect(beforeResetJson).toHaveProperty('files');
expect(beforeResetJson.files).toHaveLength(1);
expect(beforeResetJson.files[0]).toHaveProperty('basename');
expect(beforeResetJson.files[0].basename).toBe('hello.txt');
}).toPass({
intervals: [1_000, 3_000, 5_000],
});
const beforeResetResponse = await page.request.get(`${testEnv.serverBaseUrl}/hg/${tempProject.code}/file/tip?style=json-lex`);
const beforeResetJson = await beforeResetResponse.json() as HgWebJson;
expect(beforeResetJson).toHaveProperty('node');
expect(beforeResetJson.node).not.toEqual(allZeroHash);
expect(beforeResetJson).toHaveProperty('files');
expect(beforeResetJson.files).toHaveLength(1);
expect(beforeResetJson.files[0]).toHaveProperty('basename');
expect(beforeResetJson.files[0].basename).toBe('hello.txt');

// Step 3: reset project, do not upload zip file
await projectPage.goto();
Expand All @@ -65,16 +59,11 @@ test('reset project and upload .zip file', async ({ page, tempProject, tempDir }
await resetProjectModel.assertGone();

// Step 4: confirm it's empty now
// It can take a while for the server to pick up the new repo
await expect(async () => {
const afterResetResponse = await page.request.get(`${testEnv.serverBaseUrl}/hg/${tempProject.code}/file/tip?style=json-lex`);
const afterResetJson = await afterResetResponse.json() as HgWebJson;
expect(afterResetJson.node).toEqual(allZeroHash);
expect(afterResetJson).toHaveProperty('files');
expect(afterResetJson.files).toHaveLength(0);
}).toPass({
intervals: [1_000, 3_000, 5_000],
});
const afterResetResponse = await page.request.get(`${testEnv.serverBaseUrl}/hg/${tempProject.code}/file/tip?style=json-lex`);
const afterResetJson = await afterResetResponse.json() as HgWebJson;
expect(afterResetJson.node).toEqual(allZeroHash);
expect(afterResetJson).toHaveProperty('files');
expect(afterResetJson.files).toHaveLength(0);

// Step 5: reset project again, uploading zip file downloaded from step 1
await projectPage.goto();
Expand All @@ -88,12 +77,7 @@ test('reset project and upload .zip file', async ({ page, tempProject, tempDir }
await resetProjectModel.assertGone();

// Step 6: confirm tip hash and contents are same as before reset
// It can take a while for the server to pick up the new repo
await expect(async () => {
const afterUploadResponse = await page.request.get(`${testEnv.serverBaseUrl}/hg/${tempProject.code}/file/tip?style=json-lex`);
const afterResetJSon = await afterUploadResponse.json() as HgWebJson;
expect(afterResetJSon).toEqual(beforeResetJson); // NOT .toBe(), which would check that they're the same object.
}).toPass({
intervals: [1_000, 3_000, 5_000],
});
const afterUploadResponse = await page.request.get(`${testEnv.serverBaseUrl}/hg/${tempProject.code}/file/tip?style=json-lex`);
const afterResetJSon = await afterUploadResponse.json() as HgWebJson;
expect(afterResetJSon).toEqual(beforeResetJson); // NOT .toBe(), which would check that they're the same object.
});
6 changes: 5 additions & 1 deletion hgweb/command-runner.sh
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
#!/bin/bash

# Define the list of allowed commands
allowed_commands=("verify" "tip" "wesaylexentrycount" "lexentrycount" "recover" "healthz")
allowed_commands=("verify" "tip" "wesaylexentrycount" "lexentrycount" "recover" "healthz" "invalidatedircache")

# Get the project code and command name from the URL
IFS='/' read -ra PATH_SEGMENTS <<< "$PATH_INFO"
Expand Down Expand Up @@ -44,6 +44,10 @@ echo ""

# Run the hg command, simply output to stdout
first_char=$(echo $project_code | cut -c1)
# Ensure NFS cache is refreshed in case project repo changed in another pod (e.g., project reset)
ls /var/hg/repos/$first_char/$project_code/.hg >/dev/null 2>/dev/null # Don't need output; this is enough to refresh NFS dir cache
# Sometimes invalidatedircache is called after deleting a project, so the cd would fail. So exit fast in that case.
[ "x$command_name" = "xinvalidatedircache" ] && exit 0
rmunn marked this conversation as resolved.
Show resolved Hide resolved
cd /var/hg/repos/$first_char/$project_code
case $command_name in

Expand Down
Loading