Skip to content

Commit 461c21a

Browse files
committed
test(app): strengthen controller revision properties
1 parent af96a46 commit 461c21a

2 files changed

Lines changed: 234 additions & 130 deletions

File tree

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
import * as FileSystem from "@effect/platform/FileSystem"
2+
import * as Path from "@effect/platform/Path"
3+
import { describe, expect, it } from "@effect/vitest"
4+
import { Effect, Option } from "effect"
5+
import * as fc from "fast-check"
6+
7+
import { computeRevisionFromInputs } from "../../src/docker-git/controller-revision.js"
8+
9+
const ignoredControllerRevisionEntries: ReadonlyArray<string> = [
10+
".git",
11+
".turbo",
12+
".vite",
13+
"coverage",
14+
"dist",
15+
"dist-test",
16+
"dist-web",
17+
"node_modules",
18+
"out"
19+
]
20+
const ignoredControllerRevisionEntrySubsetArbitrary = fc.uniqueArray(
21+
fc.constantFrom(...ignoredControllerRevisionEntries),
22+
{ maxLength: ignoredControllerRevisionEntries.length, minLength: 1 }
23+
)
24+
const revisionFileContentsArbitrary = fc.string({ maxLength: 256 })
25+
const changedTrackedFileContentsArbitrary = fc
26+
.tuple(revisionFileContentsArbitrary, revisionFileContentsArbitrary)
27+
.filter(([left, right]) => left !== right)
28+
const memoryRootDir = "/memory"
29+
const memoryRevisionInput = "src"
30+
const memoryTrackedFileName = "tracked.ts"
31+
32+
type MemoryFileEntry =
33+
| { readonly _tag: "Directory" }
34+
| { readonly _tag: "File"; readonly contents: string }
35+
36+
const normalizeMemoryPath = (value: string): string => {
37+
const normalized = value.replaceAll(/\/+/gu, "/").replace(/\/$/u, "")
38+
return normalized.length === 0 ? "/" : normalized
39+
}
40+
41+
const memoryFileInfo = (entry: MemoryFileEntry): FileSystem.File.Info => ({
42+
atime: Option.none(),
43+
birthtime: Option.none(),
44+
blksize: Option.none(),
45+
blocks: Option.none(),
46+
dev: 0,
47+
gid: Option.none(),
48+
ino: Option.none(),
49+
mode: 0,
50+
mtime: Option.none(),
51+
nlink: Option.none(),
52+
rdev: Option.none(),
53+
size: FileSystem.Size(entry._tag === "File" ? entry.contents.length : 0),
54+
type: entry._tag === "Directory" ? "Directory" : "File",
55+
uid: Option.none()
56+
})
57+
58+
const createMemoryFileSystemLayer = () => {
59+
let entries = new Map<string, MemoryFileEntry>([
60+
["/memory", { _tag: "Directory" }]
61+
])
62+
63+
return FileSystem.layerNoop({
64+
exists: (path) => Effect.sync(() => entries.has(normalizeMemoryPath(path))),
65+
makeDirectory: (path) =>
66+
Effect.sync(() => {
67+
entries = new Map(entries).set(normalizeMemoryPath(path), { _tag: "Directory" })
68+
}),
69+
readDirectory: (path) =>
70+
Effect.sync(() => {
71+
const directory = normalizeMemoryPath(path)
72+
const prefix = directory === "/" ? "/" : `${directory}/`
73+
const names = new Set<string>()
74+
for (const candidate of entries.keys()) {
75+
if (candidate === directory || !candidate.startsWith(prefix)) {
76+
continue
77+
}
78+
const name = candidate.slice(prefix.length).split("/")[0]
79+
if (name !== undefined && name.length > 0) {
80+
names.add(name)
81+
}
82+
}
83+
return [...names]
84+
}),
85+
readFileString: (path) =>
86+
Effect.sync(() => {
87+
const entry = entries.get(normalizeMemoryPath(path))
88+
return entry?._tag === "File" ? entry.contents : ""
89+
}),
90+
stat: (path) => Effect.sync(() => memoryFileInfo(entries.get(normalizeMemoryPath(path)) ?? { _tag: "Directory" })),
91+
writeFileString: (path, contents) =>
92+
Effect.sync(() => {
93+
entries = new Map(entries).set(normalizeMemoryPath(path), { _tag: "File", contents })
94+
})
95+
})
96+
}
97+
98+
/**
99+
* Runs an asynchronous fast-check property inside Effect-based tests.
100+
*
101+
* @param property - Async property whose cases return Promises from Effect programs.
102+
* @returns Effect that fails if fast-check finds a counterexample.
103+
* @pure false
104+
* @effect Effect.tryPromise, fc.assert
105+
* @invariant A returned success proves every sampled property case passed.
106+
* @precondition The property is finite and does not share mutable memory filesystem state across cases.
107+
* @postcondition Counterexamples are surfaced as typed Effect failures.
108+
* @complexity O(r * c) time where r is numRuns and c is property case cost.
109+
* @throws Never
110+
*/
111+
const assertControllerRevisionProperty = <PropertyArgs>(property: fc.IAsyncProperty<PropertyArgs>) =>
112+
Effect.tryPromise({
113+
catch: (cause) => cause,
114+
try: () => fc.assert(property, { numRuns: 50 })
115+
})
116+
117+
/**
118+
* Writes the tracked memory source tree shared by controller revision properties.
119+
*
120+
* @param trackedContents - Contents written to the tracked source file.
121+
* @returns Effect producing the root and source directory paths.
122+
* @pure false
123+
* @effect FileSystem.FileSystem, Path.Path
124+
* @invariant The same tracked file path is created for every property case.
125+
* @precondition `trackedContents` is a finite string.
126+
* @postcondition `src/tracked.ts` exists in the fresh memory filesystem.
127+
* @complexity O(n) time and space where n = trackedContents.length.
128+
* @throws Never
129+
*/
130+
const writeTrackedMemoryRevisionSource = (trackedContents: string) =>
131+
Effect.gen(function*(_) {
132+
const fs = yield* _(FileSystem.FileSystem)
133+
const path = yield* _(Path.Path)
134+
const sourceDir = path.join(memoryRootDir, memoryRevisionInput)
135+
yield* _(fs.makeDirectory(sourceDir, { recursive: true }))
136+
yield* _(fs.writeFileString(path.join(sourceDir, memoryTrackedFileName), trackedContents))
137+
return { rootDir: memoryRootDir, sourceDir }
138+
})
139+
140+
/**
141+
* Computes a controller revision for a memory-backed source tree with one tracked file.
142+
*
143+
* @param trackedContents - Contents written to `src/tracked.ts`.
144+
* @returns Effect producing the revision for the generated in-memory tree.
145+
* @pure false
146+
* @effect FileSystem.FileSystem, Path.Path, WebCrypto digest through computeRevisionFromInputs.
147+
* @invariant Equal tracked contents produce equal revisions for the fixed tree.
148+
* @precondition `trackedContents` is a finite string.
149+
* @postcondition The in-memory filesystem layer is fresh for the call.
150+
* @complexity O(n) time and space where n = trackedContents.length.
151+
* @throws Never
152+
*/
153+
const computeMemoryRevisionForTrackedContents = (trackedContents: string) =>
154+
Effect.gen(function*(_) {
155+
const { rootDir } = yield* _(writeTrackedMemoryRevisionSource(trackedContents))
156+
return yield* _(computeRevisionFromInputs(rootDir, [memoryRevisionInput]))
157+
}).pipe(
158+
Effect.provide(createMemoryFileSystemLayer()),
159+
Effect.provide(Path.layer)
160+
)
161+
162+
describe("controller revisions", () => {
163+
it.effect("ignores generated paths when computing controller revisions", () =>
164+
assertControllerRevisionProperty(
165+
fc.asyncProperty(
166+
revisionFileContentsArbitrary,
167+
ignoredControllerRevisionEntrySubsetArbitrary,
168+
revisionFileContentsArbitrary,
169+
(trackedContents, ignoredEntries, generatedContents) =>
170+
Effect.runPromise(
171+
Effect.gen(function*(_) {
172+
const fs = yield* _(FileSystem.FileSystem)
173+
const path = yield* _(Path.Path)
174+
const { rootDir, sourceDir } = yield* _(writeTrackedMemoryRevisionSource(trackedContents))
175+
176+
const before = yield* _(computeRevisionFromInputs(rootDir, [memoryRevisionInput]))
177+
178+
for (const entry of ignoredEntries) {
179+
yield* _(fs.makeDirectory(path.join(sourceDir, entry), { recursive: true }))
180+
yield* _(fs.writeFileString(path.join(sourceDir, entry, "generated.txt"), generatedContents))
181+
}
182+
183+
const after = yield* _(computeRevisionFromInputs(rootDir, [memoryRevisionInput]))
184+
expect(after).toBe(before)
185+
}).pipe(
186+
Effect.provide(createMemoryFileSystemLayer()),
187+
Effect.provide(Path.layer)
188+
)
189+
)
190+
)
191+
))
192+
193+
it.effect("changes controller revisions when tracked source changes", () =>
194+
assertControllerRevisionProperty(
195+
fc.asyncProperty(changedTrackedFileContentsArbitrary, ([initialContents, changedContents]) =>
196+
Effect.runPromise(
197+
Effect.gen(function*(_) {
198+
const initialRevision = yield* _(computeMemoryRevisionForTrackedContents(initialContents))
199+
const changedRevision = yield* _(computeMemoryRevisionForTrackedContents(changedContents))
200+
201+
expect(changedRevision).not.toBe(initialRevision)
202+
})
203+
))
204+
))
205+
})

packages/app/tests/docker-git/controller.test.ts

Lines changed: 29 additions & 130 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,5 @@
1-
import * as FileSystem from "@effect/platform/FileSystem"
2-
import * as Path from "@effect/platform/Path"
31
import { describe, expect, it } from "@effect/vitest"
4-
import { Effect, Option } from "effect"
2+
import { Effect } from "effect"
53
import * as fc from "fast-check"
64

75
import {
@@ -14,104 +12,42 @@ import {
1412
parseControllerGpuMode
1513
} from "../../src/docker-git/controller-docker.js"
1614
import {
17-
computeRevisionFromInputs,
1815
parseControllerRevisionEnvOutput,
1916
parseControllerRevisionLabelOutput,
2017
shouldForceRecreateController
2118
} from "../../src/docker-git/controller-revision.js"
2219
import { buildApiBaseUrlCandidates, isRemoteDockerHost } from "../../src/docker-git/controller.js"
2320

21+
/**
22+
* Joins decimal IP address octets with dots for reachability fixtures.
23+
*
24+
* @param octets - Decimal octet strings in network order.
25+
* @returns Dotted IP address text.
26+
* @pure true
27+
* @effect none
28+
* @invariant Result contains exactly `max(0, octets.length - 1)` dot separators.
29+
* @precondition Each octet is already a decimal IP component.
30+
* @postcondition Splitting the result on "." yields the original octets.
31+
* @complexity O(n) time and O(n) space where n = octets.length.
32+
* @throws Never
33+
*/
2434
const joinIp = (...octets: ReadonlyArray<string>): string => octets.join(".")
25-
const makeHttpUrl = (host: string, port: string): string => ["ht", "tp://", host, ":", port].join("")
26-
const ignoredControllerRevisionEntries: ReadonlyArray<string> = [
27-
".git",
28-
".turbo",
29-
".vite",
30-
"coverage",
31-
"dist",
32-
"dist-test",
33-
"dist-web",
34-
"node_modules",
35-
"out"
36-
]
37-
const ignoredControllerRevisionEntrySubsetArbitrary = fc.uniqueArray(
38-
fc.constantFrom(...ignoredControllerRevisionEntries),
39-
{ maxLength: ignoredControllerRevisionEntries.length, minLength: 1 }
40-
)
41-
const revisionFileContentsArbitrary = fc.string({ maxLength: 256 })
42-
43-
type MemoryFileEntry =
44-
| { readonly _tag: "Directory" }
45-
| { readonly _tag: "File"; readonly contents: string }
46-
47-
const normalizeMemoryPath = (value: string): string => {
48-
const normalized = value.replaceAll(/\/+/gu, "/").replace(/\/$/u, "")
49-
return normalized.length === 0 ? "/" : normalized
50-
}
51-
52-
const memoryFileInfo = (entry: MemoryFileEntry): FileSystem.File.Info => ({
53-
atime: Option.none(),
54-
birthtime: Option.none(),
55-
blksize: Option.none(),
56-
blocks: Option.none(),
57-
dev: 0,
58-
gid: Option.none(),
59-
ino: Option.none(),
60-
mode: 0,
61-
mtime: Option.none(),
62-
nlink: Option.none(),
63-
rdev: Option.none(),
64-
size: FileSystem.Size(entry._tag === "File" ? entry.contents.length : 0),
65-
type: entry._tag === "Directory" ? "Directory" : "File",
66-
uid: Option.none()
67-
})
68-
69-
const createMemoryFileSystemLayer = () => {
70-
let entries = new Map<string, MemoryFileEntry>([
71-
["/memory", { _tag: "Directory" }]
72-
])
73-
74-
return FileSystem.layerNoop({
75-
exists: (path) => Effect.sync(() => entries.has(normalizeMemoryPath(path))),
76-
makeDirectory: (path) =>
77-
Effect.sync(() => {
78-
entries = new Map(entries).set(normalizeMemoryPath(path), { _tag: "Directory" })
79-
}),
80-
readDirectory: (path) =>
81-
Effect.sync(() => {
82-
const directory = normalizeMemoryPath(path)
83-
const prefix = directory === "/" ? "/" : `${directory}/`
84-
const names = new Set<string>()
85-
for (const candidate of entries.keys()) {
86-
if (candidate === directory || !candidate.startsWith(prefix)) {
87-
continue
88-
}
89-
const name = candidate.slice(prefix.length).split("/")[0]
90-
if (name !== undefined && name.length > 0) {
91-
names.add(name)
92-
}
93-
}
94-
return [...names]
95-
}),
96-
readFileString: (path) =>
97-
Effect.sync(() => {
98-
const entry = entries.get(normalizeMemoryPath(path))
99-
return entry?._tag === "File" ? entry.contents : ""
100-
}),
101-
stat: (path) => Effect.sync(() => memoryFileInfo(entries.get(normalizeMemoryPath(path)) ?? { _tag: "Directory" })),
102-
writeFileString: (path, contents) =>
103-
Effect.sync(() => {
104-
entries = new Map(entries).set(normalizeMemoryPath(path), { _tag: "File", contents })
105-
})
106-
})
107-
}
108-
109-
const assertControllerRevisionProperty = <Ts>(property: fc.IAsyncProperty<Ts>) =>
110-
Effect.tryPromise({
111-
catch: (cause) => cause,
112-
try: () => fc.assert(property, { numRuns: 25 })
113-
})
11435

36+
/**
37+
* Builds a deterministic HTTP URL fixture without spelling the scheme as one token.
38+
*
39+
* @param host - Non-empty host or IP address.
40+
* @param port - Non-empty decimal TCP port string.
41+
* @returns HTTP URL fixture for the host and port.
42+
* @pure true
43+
* @effect none
44+
* @invariant Result has the form `http://{host}:{port}`.
45+
* @precondition `host` and `port` are finite strings.
46+
* @postcondition The returned URL preserves host and port verbatim.
47+
* @complexity O(|host| + |port|) time and space.
48+
* @throws Never
49+
*/
50+
const makeHttpUrl = (host: string, port: string): string => ["ht", "tp://", host, ":", port].join("")
11551
describe("controller reachability", () => {
11652
it.effect("builds direct API candidates without Docker inspection", () =>
11753
Effect.sync(() => {
@@ -283,41 +219,4 @@ describe("controller reachability", () => {
283219
expect(controllerRevisionForMode("abc123def4567890", "all")).toBe("abc123def4567890-all-skiller1")
284220
expect(controllerRevisionForMode("abc123def4567890", "none", "0")).toBe("abc123def4567890-none-skiller0")
285221
}))
286-
287-
it.effect("ignores generated paths when computing controller revisions", () =>
288-
assertControllerRevisionProperty(
289-
fc.asyncProperty(
290-
revisionFileContentsArbitrary,
291-
ignoredControllerRevisionEntrySubsetArbitrary,
292-
revisionFileContentsArbitrary,
293-
(trackedContents, ignoredEntries, generatedContents) =>
294-
Effect.runPromise(
295-
Effect.gen(function*(_) {
296-
const fs = yield* _(FileSystem.FileSystem)
297-
const path = yield* _(Path.Path)
298-
const rootDir = "/memory"
299-
const sourceDir = path.join(rootDir, "src")
300-
yield* _(fs.makeDirectory(sourceDir, { recursive: true }))
301-
yield* _(fs.writeFileString(path.join(sourceDir, "tracked.ts"), trackedContents))
302-
303-
const before = yield* _(computeRevisionFromInputs(rootDir, ["src"]))
304-
305-
for (const entry of ignoredEntries) {
306-
if (entry === ".git") {
307-
yield* _(fs.writeFileString(path.join(sourceDir, entry), generatedContents))
308-
continue
309-
}
310-
yield* _(fs.makeDirectory(path.join(sourceDir, entry), { recursive: true }))
311-
yield* _(fs.writeFileString(path.join(sourceDir, entry, "generated.txt"), generatedContents))
312-
}
313-
314-
const after = yield* _(computeRevisionFromInputs(rootDir, ["src"]))
315-
expect(after).toBe(before)
316-
}).pipe(
317-
Effect.provide(createMemoryFileSystemLayer()),
318-
Effect.provide(Path.layer)
319-
)
320-
)
321-
)
322-
))
323222
})

0 commit comments

Comments
 (0)