Improve tests
This commit is contained in:
parent
b4b4d1453b
commit
12d85f567e
15 changed files with 63 additions and 119 deletions
|
|
@ -7,6 +7,12 @@ export const binaryPendingCreateNotDisplacedTest: TestDefinition = {
|
||||||
"After syncing, both files should exist on both clients at separate paths.",
|
"After syncing, both files should exist on both clients at separate paths.",
|
||||||
clients: 2,
|
clients: 2,
|
||||||
steps: [
|
steps: [
|
||||||
|
// Bootstrap both empty clients before making independent documents.
|
||||||
|
{ type: "enable-sync", client: 0 },
|
||||||
|
{ type: "enable-sync", client: 1 },
|
||||||
|
{ type: "barrier" },
|
||||||
|
{ type: "disable-sync", client: 0 },
|
||||||
|
{ type: "disable-sync", client: 1 },
|
||||||
{
|
{
|
||||||
type: "create",
|
type: "create",
|
||||||
client: 0,
|
client: 0,
|
||||||
|
|
@ -29,7 +35,7 @@ export const binaryPendingCreateNotDisplacedTest: TestDefinition = {
|
||||||
verify: (s: AssertableState): void => {
|
verify: (s: AssertableState): void => {
|
||||||
s.assertFileCount(2)
|
s.assertFileCount(2)
|
||||||
.assertFileExists("data.bin")
|
.assertFileExists("data.bin")
|
||||||
.assertFileExists("data (1).bin")
|
.assertFileExists(s.conflictPath("data.bin"))
|
||||||
.assertAnyFileContains(
|
.assertAnyFileContains(
|
||||||
"binary data from client 0",
|
"binary data from client 0",
|
||||||
"binary data from client 1"
|
"binary data from client 1"
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ export const concurrentRenameAndCreateAtTargetCreateFirstTest: TestDefinition =
|
||||||
verify: (state: AssertableState): void => {
|
verify: (state: AssertableState): void => {
|
||||||
state
|
state
|
||||||
.assertFileCount(2)
|
.assertFileCount(2)
|
||||||
.assertContains("Y (1).md", "original file X")
|
.assertContains(state.conflictPath("Y.md"), "original file X")
|
||||||
.assertContains("Y.md", "brand new Y content");
|
.assertContains("Y.md", "brand new Y content");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,7 +41,7 @@ export const concurrentRenameAndCreateAtTargetRenameFirstTest: TestDefinition =
|
||||||
state
|
state
|
||||||
.assertFileNotExists("X.md")
|
.assertFileNotExists("X.md")
|
||||||
.assertFileExists("Y.md")
|
.assertFileExists("Y.md")
|
||||||
.assertFileExists("Y (1).md")
|
.assertFileExists(state.conflictPath("Y.md"))
|
||||||
.assertAnyFileContains(
|
.assertAnyFileContains(
|
||||||
"original file X",
|
"original file X",
|
||||||
"brand new Y content"
|
"brand new Y content"
|
||||||
|
|
|
||||||
|
|
@ -52,9 +52,9 @@ export const concurrentRenameFirstWinsTest: TestDefinition = {
|
||||||
type: "assert-consistent",
|
type: "assert-consistent",
|
||||||
verify: (s: AssertableState): void => {
|
verify: (s: AssertableState): void => {
|
||||||
s.assertFileNotExists("A.md")
|
s.assertFileNotExists("A.md")
|
||||||
.assertFileCount(2)
|
.assertFileCount(1)
|
||||||
.assertContent("B.md", "edit from 0\nline 2\nline 3")
|
.assertFileNotExists("C.md")
|
||||||
.assertContent("C.md", "line 1\nline 2\nedit from 1");
|
.assertContent("B.md", "edit from 0\nline 2\nedit from 1");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ export const concurrentRenameSameTargetTest: TestDefinition = {
|
||||||
.assertFileNotExists("A.md")
|
.assertFileNotExists("A.md")
|
||||||
.assertFileNotExists("B.md")
|
.assertFileNotExists("B.md")
|
||||||
.assertFileExists("C.md")
|
.assertFileExists("C.md")
|
||||||
.assertFileExists("C (1).md")
|
.assertFileExists(state.conflictPath("C.md"))
|
||||||
.assertAnyFileContains("content-a", "content-b");
|
.assertAnyFileContains("content-a", "content-b");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import type { TestDefinition } from "../test-definition";
|
||||||
export const deleteRecreateDifferentContentTest: TestDefinition = {
|
export const deleteRecreateDifferentContentTest: TestDefinition = {
|
||||||
description:
|
description:
|
||||||
"Client 0 deletes and recreates A.md with new content offline while client 1 edits A.md offline. " +
|
"Client 0 deletes and recreates A.md with new content offline while client 1 edits A.md offline. " +
|
||||||
"Both clients should converge with content from both sides merged.",
|
"Recreation gets a new UUID; an update to the deleted UUID must not edit the replacement.",
|
||||||
clients: 2,
|
clients: 2,
|
||||||
steps: [
|
steps: [
|
||||||
{
|
{
|
||||||
|
|
@ -43,11 +43,7 @@ export const deleteRecreateDifferentContentTest: TestDefinition = {
|
||||||
{
|
{
|
||||||
type: "assert-consistent",
|
type: "assert-consistent",
|
||||||
verify: (s: AssertableState): void => {
|
verify: (s: AssertableState): void => {
|
||||||
s.assertFileCount(1).assertContains(
|
s.assertFileCount(1).assertContent("A.md", "brand new content");
|
||||||
"A.md",
|
|
||||||
"brand new",
|
|
||||||
"client 1"
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -29,9 +29,8 @@ export const offlineDeleteRemoteRenameTest: TestDefinition = {
|
||||||
{
|
{
|
||||||
type: "assert-consistent",
|
type: "assert-consistent",
|
||||||
verify: (s: AssertableState): void => {
|
verify: (s: AssertableState): void => {
|
||||||
s.assertFileNotExists("A.md").assertFileNotExists(
|
// Both membership/path changed from the base: remote wins.
|
||||||
"A_renamed.md"
|
s.assertFileCount(1).assertFileNotExists("A.md").assertContent("A_renamed.md", "content-a");
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -26,8 +26,8 @@ export const onlineBothCreateSamePathDeconflictTest: TestDefinition = {
|
||||||
type: "assert-consistent",
|
type: "assert-consistent",
|
||||||
verify: (state: AssertableState): void => {
|
verify: (state: AssertableState): void => {
|
||||||
state
|
state
|
||||||
.assertFileCount(1)
|
.assertFileCount(2)
|
||||||
.assertContains("A.md", "updated-by-0", "from-client-1 ");
|
.assertAnyFileContains("updated-by-0", "from-client-1 ");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const renameCircularTest: TestDefinition = {
|
export const renameCircularTest: TestDefinition = {
|
||||||
description:
|
description:
|
||||||
"Client 0 creates three files, syncs, then goes offline and performs a circular rename via a temp file (A->temp, C->A, B->C, temp->B). After reconnecting, all three contents should exist across three files but paths may be deconflicted.",
|
"Client 0 creates three files, syncs, then goes offline and performs a circular rename via a temp file (A->temp, C->A, B->C, temp->B). After reconnecting, all three UUIDs and exact contents must follow the requested cycle.",
|
||||||
clients: 2,
|
clients: 2,
|
||||||
steps: [
|
steps: [
|
||||||
{ type: "create", client: 0, path: "A.md", content: "content-a" },
|
{ type: "create", client: 0, path: "A.md", content: "content-a" },
|
||||||
|
|
@ -21,6 +21,9 @@ export const renameCircularTest: TestDefinition = {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
{ type: "remember-identity", key: "A", path: "A.md" },
|
||||||
|
{ type: "remember-identity", key: "B", path: "B.md" },
|
||||||
|
{ type: "remember-identity", key: "C", path: "C.md" },
|
||||||
{ type: "disable-sync", client: 0 },
|
{ type: "disable-sync", client: 0 },
|
||||||
{ type: "rename", client: 0, oldPath: "A.md", newPath: "temp-a.md" },
|
{ type: "rename", client: 0, oldPath: "A.md", newPath: "temp-a.md" },
|
||||||
{ type: "rename", client: 0, oldPath: "C.md", newPath: "A.md" },
|
{ type: "rename", client: 0, oldPath: "C.md", newPath: "A.md" },
|
||||||
|
|
@ -29,15 +32,18 @@ export const renameCircularTest: TestDefinition = {
|
||||||
|
|
||||||
{ type: "enable-sync", client: 0 },
|
{ type: "enable-sync", client: 0 },
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
|
{ type: "assert-identity", key: "A", path: "B.md" },
|
||||||
|
{ type: "assert-identity", key: "B", path: "C.md" },
|
||||||
|
{ type: "assert-identity", key: "C", path: "A.md" },
|
||||||
|
|
||||||
{
|
{
|
||||||
type: "assert-consistent",
|
type: "assert-consistent",
|
||||||
verify: (s: AssertableState): void => {
|
verify: (s: AssertableState): void => {
|
||||||
s.assertFileNotExists("temp-a.md")
|
s.assertFileNotExists("temp-a.md")
|
||||||
.assertFileCount(3)
|
.assertFileCount(3)
|
||||||
.assertAnyFileContains("content-c")
|
.assertContent("A.md", "content-c")
|
||||||
.assertAnyFileContains("content-a")
|
.assertContent("B.md", "content-a")
|
||||||
.assertAnyFileContains("content-b");
|
.assertContent("C.md", "content-b");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -27,7 +27,7 @@ export const renameCreateConflictTest: TestDefinition = {
|
||||||
verify: (s: AssertableState): void => {
|
verify: (s: AssertableState): void => {
|
||||||
s.assertFileCount(2)
|
s.assertFileCount(2)
|
||||||
.assertContent("B.md", "hi")
|
.assertContent("B.md", "hi")
|
||||||
.assertContent("B (1).md", "hi");
|
.assertContent(s.conflictPath("B.md"), "hi");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ export const renameSwapTest: TestDefinition = {
|
||||||
description:
|
description:
|
||||||
"Client 0 has A.md and B.md synced. Goes offline and swaps them using " +
|
"Client 0 has A.md and B.md synced. Goes offline and swaps them using " +
|
||||||
"a temp file: A.md -> temp.md, B.md -> A.md, temp.md -> B.md. " +
|
"a temp file: A.md -> temp.md, B.md -> A.md, temp.md -> B.md. " +
|
||||||
"When Client 0 reconnects, both contents should exist across two files.",
|
"When Client 0 reconnects, both UUIDs must follow their files to the swapped paths.",
|
||||||
clients: 2,
|
clients: 2,
|
||||||
steps: [
|
steps: [
|
||||||
{ type: "create", client: 0, path: "A.md", content: "content-a" },
|
{ type: "create", client: 0, path: "A.md", content: "content-a" },
|
||||||
|
|
@ -23,6 +23,8 @@ export const renameSwapTest: TestDefinition = {
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
|
{ type: "remember-identity", key: "A", path: "A.md" },
|
||||||
|
{ type: "remember-identity", key: "B", path: "B.md" },
|
||||||
{ type: "disable-sync", client: 0 },
|
{ type: "disable-sync", client: 0 },
|
||||||
{ type: "rename", client: 0, oldPath: "A.md", newPath: "temp.md" },
|
{ type: "rename", client: 0, oldPath: "A.md", newPath: "temp.md" },
|
||||||
{ type: "rename", client: 0, oldPath: "B.md", newPath: "A.md" },
|
{ type: "rename", client: 0, oldPath: "B.md", newPath: "A.md" },
|
||||||
|
|
@ -30,14 +32,16 @@ export const renameSwapTest: TestDefinition = {
|
||||||
|
|
||||||
{ type: "enable-sync", client: 0 },
|
{ type: "enable-sync", client: 0 },
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
|
{ type: "assert-identity", key: "A", path: "B.md" },
|
||||||
|
{ type: "assert-identity", key: "B", path: "A.md" },
|
||||||
|
|
||||||
{
|
{
|
||||||
type: "assert-consistent",
|
type: "assert-consistent",
|
||||||
verify: (s: AssertableState): void => {
|
verify: (s: AssertableState): void => {
|
||||||
s.assertFileNotExists("temp.md")
|
s.assertFileNotExists("temp.md")
|
||||||
.assertFileCount(2)
|
.assertFileCount(2)
|
||||||
.assertAnyFileContains("content-b")
|
.assertContent("A.md", "content-b")
|
||||||
.assertAnyFileContains("content-a");
|
.assertContent("B.md", "content-a");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,121 +1,50 @@
|
||||||
import type { AssertableState } from "../utils/assertable-state";
|
|
||||||
import type { TestDefinition } from "../test-definition";
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
|
// Historical registry name retained so old reproduction commands still work.
|
||||||
export const sameDocIdCollapseOnLocalCreateAfterRemoteCreateTest: TestDefinition =
|
export const sameDocIdCollapseOnLocalCreateAfterRemoteCreateTest: TestDefinition =
|
||||||
{
|
{
|
||||||
description:
|
description:
|
||||||
"Client B creates X with content C2; the server commits and " +
|
"Two initialized clients create distinct UUIDs at X.md while remote observation is held. V4 must preserve both identities/content after catchup; the server never merges creates by path.",
|
||||||
"broadcasts. Client A's WS is paused so the RemoteCreate buffers. " +
|
|
||||||
"Server is then paused so A's about-to-POST LocalCreate will " +
|
|
||||||
"hang. A creates X with content C1: file lands on disk, " +
|
|
||||||
"LocalCreate enqueues, drain starts the POST, the POST stalls " +
|
|
||||||
"at the paused server. A's WS is resumed: the buffered " +
|
|
||||||
"RemoteCreate for doc-X is delivered to A and enqueues behind " +
|
|
||||||
"the in-flight LocalCreate. Per the lazy-paths model, when " +
|
|
||||||
"the RemoteCreate is processed it observes that path X is " +
|
|
||||||
"occupied locally by A's pending-create bytes, so it tracks " +
|
|
||||||
"doc-X with `localPath = undefined` / `remoteRelativePath = " +
|
|
||||||
"X` and does NOT fetch content. The server is then resumed: " +
|
|
||||||
"A's LocalCreate POST returns. The server, finding X already " +
|
|
||||||
"taken by doc-X, replies with doc-X's existing documentId " +
|
|
||||||
"(typically a MergingUpdate carrying the merged bytes). A's " +
|
|
||||||
"processCreate handler detects that response.documentId " +
|
|
||||||
"matches the no-localPath record built from the RemoteCreate " +
|
|
||||||
"and collapses the two: it sets localPath = X on that " +
|
|
||||||
"record, writes the merged bytes, and resolves the pending " +
|
|
||||||
"create promise. Final state: exactly one file at X on both " +
|
|
||||||
"clients, both pointing at doc-X's documentId, content " +
|
|
||||||
"carrying both contributions, and no conflict-<uuid>- " +
|
|
||||||
"stash anywhere.",
|
|
||||||
clients: 2,
|
clients: 2,
|
||||||
steps: [
|
steps: [
|
||||||
{ type: "enable-sync", client: 0 },
|
{ type: "enable-sync", client: 0 },
|
||||||
{ type: "enable-sync", client: 1 },
|
{ type: "enable-sync", client: 1 },
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
|
// Freeze HTTP catchup as well as WS. Pausing only WS is insufficient.
|
||||||
// Buffer broadcasts to client 0 (A) so client 1's create
|
{ type: "pause-observation", client: 0 },
|
||||||
// doesn't reach A's WS handler until we say so.
|
|
||||||
{ type: "pause-websocket", client: 0 },
|
|
||||||
|
|
||||||
// Client 1 (B) commits doc-X at path X with content C2.
|
|
||||||
// The server commits, broadcasts (broadcast queued at A's
|
|
||||||
// paused WS).
|
|
||||||
{
|
{
|
||||||
type: "create",
|
type: "create",
|
||||||
client: 1,
|
client: 1,
|
||||||
path: "X.md",
|
path: "X.md",
|
||||||
content: "from-client-1 "
|
content: "from-client-1"
|
||||||
},
|
},
|
||||||
{ type: "sync", client: 1 },
|
{ type: "sync", client: 1 },
|
||||||
|
|
||||||
// Pause the server so A's upcoming LocalCreate POST hangs.
|
|
||||||
// This holds A's drain on the in-flight POST while we
|
|
||||||
// release the WS so the RemoteCreate enqueues behind it.
|
|
||||||
{ type: "pause-server" },
|
{ type: "pause-server" },
|
||||||
|
|
||||||
// Client 0 (A) creates X locally with content C1. The
|
|
||||||
// file lands on A's disk; LocalCreate enqueues; drain
|
|
||||||
// starts the POST; POST stalls at the paused server.
|
|
||||||
{
|
{
|
||||||
type: "create",
|
type: "create",
|
||||||
client: 0,
|
client: 0,
|
||||||
path: "X.md",
|
path: "X.md",
|
||||||
content: "from-client-0 "
|
content: "from-client-0"
|
||||||
},
|
},
|
||||||
|
{ type: "wait-for-observation", client: 0 },
|
||||||
// Release A's WS. The buffered RemoteCreate for doc-X is
|
{ type: "resume-observation", client: 0 },
|
||||||
// delivered to A and enqueues behind the in-flight
|
|
||||||
// LocalCreate. Whichever of (RemoteCreate processed first
|
|
||||||
// → no-localPath record, then LocalCreate POST returns
|
|
||||||
// with merging response that collapses) or (LocalCreate
|
|
||||||
// POST returns first with merging response that creates
|
|
||||||
// the canonical record, then RemoteCreate finds the doc
|
|
||||||
// already tracked by id and no-ops) actually plays out
|
|
||||||
// depends on the fine-grained interleaving the runtime
|
|
||||||
// produces, but both paths are required to converge to
|
|
||||||
// the same single-record same-docId state.
|
|
||||||
{ type: "resume-websocket", client: 0 },
|
|
||||||
|
|
||||||
// Resume the server: A's LocalCreate POST completes.
|
|
||||||
// Server returns doc-X's existing documentId (MergingUpdate
|
|
||||||
// with merged content). processCreate runs the collapse
|
|
||||||
// path.
|
|
||||||
{ type: "resume-server" },
|
{ type: "resume-server" },
|
||||||
|
|
||||||
{ type: "barrier" },
|
{ type: "barrier" },
|
||||||
|
|
||||||
{
|
{
|
||||||
type: "assert-consistent",
|
type: "assert-consistent",
|
||||||
verify: (state: AssertableState): void => {
|
verify: (state) => {
|
||||||
state.assertFileCount(1);
|
state
|
||||||
state.assertFileExists("X.md");
|
.assertFileCount(2)
|
||||||
// Server-side merge of the two text creates must
|
.assertContent("X.md", "from-client-1");
|
||||||
// carry both contributions through to the
|
state.assertContent(
|
||||||
// converged file.
|
state.conflictPath("X.md"),
|
||||||
state.assertContains(
|
"from-client-0"
|
||||||
"X.md",
|
|
||||||
"from-client-0",
|
|
||||||
"from-client-1"
|
|
||||||
);
|
);
|
||||||
// The lazy-paths collapse path must not leave a
|
|
||||||
// conflict-<uuid>- stash on either client.
|
|
||||||
for (const path of state.files.keys()) {
|
|
||||||
if (path.startsWith("conflict-")) {
|
|
||||||
throw new Error(
|
|
||||||
`Unexpected conflict-uuid stash on a converged client: ${path}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
for (const perClient of state.clientFiles) {
|
|
||||||
for (const path of perClient.keys()) {
|
|
||||||
if (path.startsWith("conflict-")) {
|
|
||||||
throw new Error(
|
|
||||||
`Unexpected conflict-uuid stash on a per-client view: ${path}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
type: "assert-markers",
|
||||||
|
markers: ["from-client-0", "from-client-1"]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import type { AssertableState } from "../utils/assertable-state";
|
import type { AssertableState } from "../utils/assertable-state";
|
||||||
import type { TestDefinition } from "../test-definition";
|
import type { TestDefinition } from "../test-definition";
|
||||||
|
|
||||||
export const updateDoesNotSurvivesRemoteDeleteTest: TestDefinition = {
|
export const updateDoesNotSurviveRemoteDeleteTest: TestDefinition = {
|
||||||
description:
|
description:
|
||||||
"Client 0 deletes a file while client 1 edits it offline. Client 0 syncs the delete first, then client 1 reconnects. Deletes always win.",
|
"Client 0 deletes a file while client 1 edits it offline. Client 0 syncs the delete first, then client 1 reconnects. Deletes always win.",
|
||||||
clients: 2,
|
clients: 2,
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,9 @@ export const userParenthesizedFileNotDeletedTest: TestDefinition = {
|
||||||
clients: 2,
|
clients: 2,
|
||||||
steps: [
|
steps: [
|
||||||
{ type: "enable-sync", client: 0 },
|
{ type: "enable-sync", client: 0 },
|
||||||
|
{ type: "enable-sync", client: 1 },
|
||||||
|
{ type: "barrier" },
|
||||||
|
{ type: "disable-sync", client: 1 },
|
||||||
|
|
||||||
{
|
{
|
||||||
type: "create",
|
type: "create",
|
||||||
|
|
@ -40,7 +43,8 @@ export const userParenthesizedFileNotDeletedTest: TestDefinition = {
|
||||||
.assertFileCount(3)
|
.assertFileCount(3)
|
||||||
.assertFileExists("Chapter.bin")
|
.assertFileExists("Chapter.bin")
|
||||||
.assertFileExists("Chapter (1).bin")
|
.assertFileExists("Chapter (1).bin")
|
||||||
.assertFileExists("Chapter (2).bin");
|
.assertContent("Chapter (1).bin", "chapter one notes")
|
||||||
|
.assertContent(state.conflictPath("Chapter.bin"), "chapter one notes");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ export const watermarkAdvancesOnSkipTest: TestDefinition = {
|
||||||
{
|
{
|
||||||
type: "assert-consistent",
|
type: "assert-consistent",
|
||||||
verify: (s: AssertableState): void => {
|
verify: (s: AssertableState): void => {
|
||||||
s.assertFileCount(1).assertFileExists("doc.md");
|
s.assertFileCount(2).assertFileExists("doc.md").assertAnyFileContains("from client 0", "from client 1");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue