Skip to content

Commit 44512fb

Browse files
ampagenttmm
andcommitted
test(zbugs-swift): the Swift port against zbugs' own backend
server/server.ts runs zbugs' backend from this repository's sources: a fresh database with zbugs' migrations and seed data, the API server, and zero-cache. BackendTests lists, creates, and comments through it, so the API server runs the TS mutators and queries next to the Swift ones, and checks that a second client sees the result and that both sides report zbugs' errors alike. The Linux end-to-end job runs it. Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm <tmm@tmm.dev>
1 parent ee9a0c5 commit 44512fb

5 files changed

Lines changed: 407 additions & 12 deletions

File tree

‎.github/workflows/swift.yml‎

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,30 @@ jobs:
138138
env:
139139
ZERO_E2E_URL: ws://127.0.0.1:4848
140140
run: swift test --skip-build --filter E2ETests
141-
- name: Server log
141+
# zbugs' Swift port against zbugs' backend: its migrations and seed
142+
# data, its API server running the TS queries and mutators, and
143+
# zero-cache.
144+
- name: Start zbugs' backend
145+
env:
146+
ZBUGS_PG: postgresql://postgres:postgres@127.0.0.1:5432/postgres
147+
ZBUGS_PORT: '4858'
148+
ZBUGS_API_PORT: '5183'
149+
run: |
150+
node apps/zbugs-swift/server/server.ts > "$RUNNER_TEMP/zbugs.log" 2>&1 &
151+
for _ in $(seq 1 300); do
152+
grep -q '^ready ' "$RUNNER_TEMP/zbugs.log" && exit 0
153+
sleep 1
154+
done
155+
cat "$RUNNER_TEMP/zbugs.log"
156+
exit 1
157+
- name: Test zbugs against its backend
158+
working-directory: apps/zbugs-swift
159+
run: |
160+
ZBUGS_E2E_TOKEN="$(grep '^token ' "$RUNNER_TEMP/zbugs.log" | cut -d' ' -f2)" \
161+
ZBUGS_E2E_URL=http://127.0.0.1:4858 ZBUGS_E2E_API=http://127.0.0.1:5183 \
162+
swift test --filter BackendTests
163+
- name: Server logs
142164
if: failure()
143-
run: cat "$RUNNER_TEMP/server.log"
165+
run: |
166+
cat "$RUNNER_TEMP/server.log"
167+
cat "$RUNNER_TEMP/zbugs.log" || true

‎apps/zbugs-swift/README.md‎

Lines changed: 50 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,55 @@
11
# zbugs on the Zero Swift client
22

3-
The schema, queries, and mutators of [zbugs](../zbugs) (`apps/zbugs/shared`),
4-
ported to Swift as the `ZBugs` library.
3+
[zbugs](../zbugs) as a native app for iOS and macOS:
4+
5+
- `ZBugs`: zbugs' schema, queries, and mutators (`apps/zbugs/shared`),
6+
ported to Swift. Builds on Linux too.
7+
- `ZBugsUI`: the app's screens in SwiftUI, with the issue list also in UIKit
8+
(iOS) and AppKit (macOS) through `DiffableAdapter`.
9+
- `App/`: the app target, as an [XcodeGen](https://github.com/yonaskolb/XcodeGen)
10+
project.
11+
- `server/server.ts`: zbugs' backend from this repository's sources, for the
12+
app and the end-to-end tests.
13+
14+
## Running the app
15+
16+
Start the backend. It needs Node 24, `psql`, and Postgres with
17+
`wal_level = logical` (for example `docker compose up` in
18+
`apps/zbugs/docker`, which listens on 6434):
19+
20+
```sh
21+
ZBUGS_PG=postgresql://user:password@127.0.0.1:6434/postgres \
22+
node apps/zbugs-swift/server/server.ts
23+
```
24+
25+
It recreates the `zbugs_swift` database with zbugs' seed data, then prints a
26+
token for a crew user and `ready <zero-cache> <API>` (ports 4848 and 5173).
27+
28+
Then generate the Xcode project and run the `ZBugsApp` scheme on a Mac or an
29+
iOS simulator:
30+
31+
```sh
32+
cd apps/zbugs-swift/App
33+
xcodegen generate
34+
open ZBugs.xcodeproj
35+
```
36+
37+
Log in from the account menu with the printed token, or through GitHub when
38+
the API server has `GITHUB_CLIENT_ID` and `GITHUB_CLIENT_SECRET` (it
39+
redirects back to `zbugs-swift://auth`). Servers… points the app elsewhere.
40+
41+
## End-to-end tests
42+
43+
`BackendTests` runs the Swift queries and mutators against the backend: the
44+
API server runs the TS versions, and the test checks that both sides agree.
45+
It is skipped unless the backend's URLs and a token are set:
46+
47+
```sh
48+
ZBUGS_E2E_URL=http://127.0.0.1:4848 ZBUGS_E2E_API=http://127.0.0.1:5173 \
49+
ZBUGS_E2E_TOKEN=<token> swift test --filter BackendTests
50+
```
51+
52+
## Keeping the port current
553

654
- `Sources/ZBugs/Schema.swift` is generated from `apps/zbugs/shared/schema.ts`
755
by `zero-swift codegen` (config in `zero-swift.json`).
Lines changed: 118 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,118 @@
1+
import Foundation
2+
import Testing
3+
import ZBugs
4+
import Zero
5+
import ZeroNIOTesting
6+
7+
/// Where zbugs' backend (server/server.ts) runs, and a token it accepts.
8+
enum Backend {
9+
static let env = ProcessInfo.processInfo.environment
10+
static let cacheURL = env["ZBUGS_E2E_URL"]
11+
static let apiURL = env["ZBUGS_E2E_API"]
12+
static let token = env["ZBUGS_E2E_TOKEN"]
13+
static var enabled: Bool { cacheURL != nil && apiURL != nil && token != nil }
14+
}
15+
16+
/// zbugs' Swift port against zbugs' backend: zero-cache asks the API server
17+
/// for the queries the client names, and the API server runs the TS
18+
/// mutators the client ran optimistically.
19+
@Suite(.enabled(if: Backend.enabled), .timeLimit(.minutes(2)))
20+
@MainActor
21+
struct BackendTests {
22+
func makeZero() throws -> Zero<AuthData?> {
23+
let token = Backend.token!
24+
let auth = AuthData(jwt: token, now: Date().timeIntervalSince1970)
25+
var options = ZeroOptions(
26+
cacheURL: Backend.cacheURL, userID: auth?.sub, auth: token, storage: .memory)
27+
options.mutateURL = Backend.apiURL! + "/api/mutate"
28+
options.queryURL = Backend.apiURL! + "/api/query"
29+
return try Zero(
30+
schema: .zbugs, context: auth, mutators: Mutators.all, options: options,
31+
socketFactory: TransportSocketFactory(NIOTransport()))
32+
}
33+
34+
/// Waits until `condition` holds.
35+
func eventually(
36+
_ what: String, timeout: Duration = .seconds(30), _ condition: () -> Bool
37+
) async throws {
38+
let deadline = ContinuousClock.now + timeout
39+
while !condition() {
40+
guard ContinuousClock.now < deadline else {
41+
Issue.record("Timed out waiting for \(what)")
42+
throw CancellationError()
43+
}
44+
try await Task.sleep(for: .milliseconds(20))
45+
}
46+
}
47+
48+
static let openIssues = ListContextParams(
49+
open: true, assignee: nil, creator: nil, labels: [], textFilter: nil, sortField: .modified,
50+
sortDirection: .desc)
51+
52+
@Test func listsCreatesAndComments() async throws {
53+
let zero = try makeZero()
54+
defer { zero.close() }
55+
let list = try zero.liveQuery(
56+
Queries.issueListV2(
57+
IssueListV2Args(listContext: Self.openIssues, limit: 20, start: nil, dir: .forward)))
58+
try await eventually("the issue list") { list.resultType == .complete }
59+
#expect(list.data.count == 20)
60+
#expect(list.data.allSatisfy { $0.open && $0.creator != nil })
61+
#expect(list.data.map(\.modified) == list.data.map(\.modified).sorted(by: >))
62+
63+
// The server runs the TS mutator too, and its trigger numbers the issue.
64+
let projects = try zero.liveQuery(Queries.allProjects())
65+
try await eventually("the projects") { projects.resultType == .complete }
66+
let project = try #require(projects.data.first { $0.name == zeroProjectName })
67+
let id = makeID()
68+
let now = (Date().timeIntervalSince1970 * 1000).rounded()
69+
let created = zero.mutate(
70+
Mutators.createIssue(
71+
CreateIssueArgs(
72+
id: id, title: "From Swift", description: "Hello", created: now, modified: now,
73+
projectID: project.id)))
74+
#expect(try await created.client.value == .success(data: nil))
75+
#expect(list.data.first?.id == id)
76+
#expect(try await created.server.value == .success(data: nil))
77+
78+
let detail = try zero.liveQuery(
79+
Queries.issueDetail(IssueDetailArgs(idField: .id, id: .string(id))))
80+
try await eventually("the new issue's number") { detail.data?.shortID != nil }
81+
let comment = zero.mutate(
82+
Mutators.addComment(
83+
AddCommentArgs(id: makeID(), issueID: id, body: "First", created: now + 1)))
84+
#expect(try await comment.server.value == .success(data: nil))
85+
try await eventually("the comment") { detail.data?.comments?.map(\.body) == ["First"] }
86+
// A crew member's issue notifies them.
87+
#expect(detail.data?.notificationState?.subscribed == true)
88+
89+
// Another client sees it through the server, by its number.
90+
let other = try makeZero()
91+
defer { other.close() }
92+
let shortID = try #require(detail.data?.shortID)
93+
let byNumber = try other.liveQuery(
94+
Queries.issueDetail(IssueDetailArgs(idField: .shortID, id: .number(Double(shortID)))))
95+
try await eventually("the issue on another client") { byNumber.resultType == .complete }
96+
#expect(byNumber.data?.id == id)
97+
#expect(byNumber.data?.title == "From Swift")
98+
#expect(byNumber.data?.comments?.count == 1)
99+
}
100+
101+
@Test func reportsWhatTheServerRejects() async throws {
102+
let zero = try makeZero()
103+
defer { zero.close() }
104+
let list = try zero.liveQuery(
105+
Queries.issueListV2(
106+
IssueListV2Args(listContext: Self.openIssues, limit: 1, start: nil, dir: .forward)))
107+
try await eventually("the issue list") { list.resultType == .complete }
108+
// A label for an issue that doesn't exist: the client and the server
109+
// both refuse, with zbugs' error.
110+
let result = zero.mutate(
111+
Mutators.createAndAddLabel(
112+
CreateAndAddLabelArgs(issueID: "missing", labelID: makeID(), labelName: "x")))
113+
let expected = MutationOutcome.appError(
114+
message: "Issue not found", details: ["code": "ENTITY_NOT_FOUND", "id": "missing"])
115+
#expect(try await result.client.value == expected)
116+
#expect(try await result.server.value == expected)
117+
}
118+
}

‎apps/zbugs-swift/server/server.ts‎

Lines changed: 186 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,186 @@
1+
/**
2+
* Runs zbugs' backend for the Swift app, in development and in the
3+
* end-to-end tests: a fresh Postgres database with zbugs' migrations and
4+
* seed data, zbugs' API server (apps/zbugs/api), and zero-cache, all from
5+
* this repository's sources.
6+
*
7+
* node apps/zbugs-swift/server/server.ts
8+
*
9+
* Needs Node 24 and `psql` on the PATH, and Postgres with
10+
* `wal_level = logical`. Prints a token for the seeded crew user, then
11+
* `ready <zero-cache URL> <API URL>` once both serve, and stops everything
12+
* on SIGINT or SIGTERM.
13+
*
14+
* Environment:
15+
* ZBUGS_PG admin connection URL
16+
* (default postgresql://postgres@127.0.0.1:5432/postgres)
17+
* ZBUGS_DB database to (re)create (default zbugs_swift)
18+
* ZBUGS_SEED seed data directory in apps/zbugs/db/seed-data
19+
* (default github)
20+
* ZBUGS_HOST interface to listen on (default 127.0.0.1)
21+
* ZBUGS_PORT zero-cache port (default 4848)
22+
* ZBUGS_API_PORT API server port (default 5173)
23+
* ZBUGS_DIR working directory for the replica (default a temp dir)
24+
*/
25+
import {execFileSync, spawn} from 'node:child_process';
26+
import {mkdtempSync, readdirSync} from 'node:fs';
27+
import {registerHooks} from 'node:module';
28+
import {tmpdir} from 'node:os';
29+
import {join} from 'node:path';
30+
import {fileURLToPath, pathToFileURL} from 'node:url';
31+
import {createJwkPair} from '../../../packages/zero-cache/src/auth/jwt.ts';
32+
33+
const env = process.env;
34+
const adminURL =
35+
env['ZBUGS_PG'] ?? 'postgresql://postgres@127.0.0.1:5432/postgres';
36+
const dbName = env['ZBUGS_DB'] ?? 'zbugs_swift';
37+
const host = env['ZBUGS_HOST'] ?? '127.0.0.1';
38+
const port = Number(env['ZBUGS_PORT'] ?? 4848);
39+
const apiPort = Number(env['ZBUGS_API_PORT'] ?? 5173);
40+
const workDir = env['ZBUGS_DIR'] ?? mkdtempSync(join(tmpdir(), 'zbugs-swift-'));
41+
42+
const here = (path: string) => fileURLToPath(new URL(path, import.meta.url));
43+
const zbugs = here('../../zbugs/');
44+
const upstreamURL = (() => {
45+
const url = new URL(adminURL);
46+
url.pathname = `/${dbName}`;
47+
return url.toString();
48+
})();
49+
50+
function psql(url: string, ...args: string[]) {
51+
execFileSync('psql', [url, '-v', 'ON_ERROR_STOP=1', '-q', ...args], {
52+
stdio: ['ignore', 'ignore', 'inherit'],
53+
});
54+
}
55+
56+
// The database: zbugs' migrations, then its seed script.
57+
psql(adminURL, '-c', `DROP DATABASE IF EXISTS ${dbName} WITH (FORCE)`);
58+
psql(adminURL, '-c', `CREATE DATABASE ${dbName}`);
59+
const migrations = join(zbugs, 'db/migrations');
60+
for (const file of readdirSync(migrations)
61+
.filter(f => f.endsWith('.sql'))
62+
.sort()) {
63+
// In a transaction each, as drizzle-kit applies them.
64+
psql(upstreamURL, '--single-transaction', '-f', join(migrations, file));
65+
}
66+
execFileSync(process.execPath, [join(zbugs, 'scripts/seed.ts')], {
67+
cwd: zbugs,
68+
stdio: ['ignore', 'ignore', 'inherit'],
69+
env: {
70+
...env,
71+
['ZERO_UPSTREAM_DB']: upstreamURL,
72+
['ZERO_SEED_DATA_DIR']: join(
73+
zbugs,
74+
'db/seed-data',
75+
env['ZBUGS_SEED'] ?? 'github',
76+
),
77+
},
78+
});
79+
const crewID = execFileSync(
80+
'psql',
81+
[
82+
upstreamURL,
83+
'-tAc',
84+
`SELECT id FROM "user" WHERE role = 'crew' ORDER BY login LIMIT 1`,
85+
],
86+
{encoding: 'utf8'},
87+
).trim();
88+
89+
// The API server, as `pnpm dev` runs it in apps/zbugs, with keys of its own.
90+
const {privateJwk, publicJwk} = await createJwkPair();
91+
env['ZERO_UPSTREAM_DB'] = upstreamURL;
92+
env['PRIVATE_JWK'] = JSON.stringify(privateJwk);
93+
env['VITE_PUBLIC_JWK'] = JSON.stringify(publicJwk);
94+
const zeroSources: Record<string, string> = {
95+
'@rocicorp/zero': 'zero.ts',
96+
'@rocicorp/zero/server': 'server.ts',
97+
'@rocicorp/zero/server/adapters/postgresjs': 'adapters/postgresjs.ts',
98+
};
99+
// What Vite does for apps/zbugs: `@rocicorp/zero` is its sources, and a
100+
// relative `.js` import may name a `.ts` file.
101+
registerHooks({
102+
resolve(specifier, context, next) {
103+
const source = zeroSources[specifier];
104+
if (source) {
105+
return {
106+
url: pathToFileURL(here(`../../../packages/zero/src/${source}`)).href,
107+
shortCircuit: true,
108+
};
109+
}
110+
try {
111+
return next(specifier, context);
112+
} catch (e) {
113+
if (specifier.startsWith('.') && specifier.endsWith('.js')) {
114+
return next(specifier.slice(0, -3) + '.ts', context);
115+
}
116+
throw e;
117+
}
118+
},
119+
});
120+
const {fastify} = (await import('../../zbugs/api/index.ts')) as {
121+
fastify: {
122+
listen(o: {port: number; host: string}): Promise<string>;
123+
close(): Promise<void>;
124+
};
125+
};
126+
await fastify.listen({port: apiPort, host});
127+
128+
const zeroCache = spawn(
129+
process.execPath,
130+
[here('../../../packages/zero-cache/src/server/runner/main.ts')],
131+
{
132+
stdio: ['ignore', 'inherit', 'inherit'],
133+
env: {
134+
...env,
135+
['NODE_ENV']: 'development',
136+
['ZERO_UPSTREAM_DB']: upstreamURL,
137+
['ZERO_REPLICA_FILE']: join(workDir, 'replica.db'),
138+
['ZERO_PORT']: String(port),
139+
['ZERO_QUERY_URL']: `http://${host}:${apiPort}/api/query`,
140+
['ZERO_MUTATE_URL']: `http://${host}:${apiPort}/api/mutate`,
141+
['ZERO_NUM_SYNC_WORKERS']: '1',
142+
['ZERO_LOG_LEVEL']: env['ZERO_LOG_LEVEL'] ?? 'info',
143+
},
144+
},
145+
);
146+
147+
function stop(code: number) {
148+
zeroCache.kill('SIGTERM');
149+
void fastify.close();
150+
process.exit(code);
151+
}
152+
zeroCache.on('exit', code => {
153+
process.stderr.write(`zero-cache exited with ${code}\n`);
154+
stop(1);
155+
});
156+
process.on('SIGINT', () => stop(0));
157+
process.on('SIGTERM', () => stop(0));
158+
159+
for (let i = 0; ; i++) {
160+
try {
161+
const res = await fetch(`http://${host}:${port}/`);
162+
if (res.ok) {
163+
break;
164+
}
165+
} catch {
166+
// Not listening yet.
167+
}
168+
if (i > 600) {
169+
process.stderr.write('zero-cache did not start\n');
170+
stop(1);
171+
}
172+
await new Promise(resolve => setTimeout(resolve, 100));
173+
}
174+
175+
// The token a client logs in with: the crew user's, as `pnpm dev-token`
176+
// mints it.
177+
const token = execFileSync(
178+
process.execPath,
179+
[join(zbugs, 'scripts/dev-token.ts'), crewID, 'crew'],
180+
{
181+
cwd: zbugs,
182+
encoding: 'utf8',
183+
},
184+
).trim();
185+
process.stdout.write(`token ${token}\n`);
186+
process.stdout.write(`ready ws://${host}:${port} http://${host}:${apiPort}\n`);

0 commit comments

Comments
 (0)