Skip to content

Commit 5251897

Browse files
tmmampagent
andauthored
Swift follow-ups: CLI usage and errors, faster UI CI, ZeroVirtual docs (#2)
* fix(zero-swift-cli): --help prints usage, and a missing file or unknown option gets a message, not a stack trace Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm <tmm@tmm.dev> * ci(zbugs-swift): a zbugs change skips the virtual list suites, a test app change skips the screenshots, and a lost tap on an issue is tried again A changes job diffs the push or pull request and runs the screenshots for apps/zbugs-swift, the eight virtual list jobs for swift/TestHost, and both for the UI libraries or the workflow. The iPad's comment button flake at d67d76c was a tap that found its row only after 23 s of list updates and didn't open the issue; the test now taps again if the issue hasn't opened in 10 s. Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm <tmm@tmm.dev> * docs(zero-swift): the README covers ZeroVirtual Amp-Thread-ID: https://ampcode.com/threads/T-c513a037-a257-45e0-b9a9-15c15edfe859 Co-authored-by: tmm <tmm@tmm.dev> --------- Co-authored-by: Amp <amp@ampcode.com>
1 parent 8e078f4 commit 5251897

9 files changed

Lines changed: 181 additions & 22 deletions

File tree

‎.changeset/cli-usage.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'zero-swift': patch
3+
---
4+
5+
`zero-swift --help` prints usage, and a missing schema, samples, or config file or an unknown option gets a one-line message instead of a stack trace.

‎.github/workflows/zbugs-screenshots.yml‎

Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,8 @@ name: zbugs screenshots
88
# UI tests run the ZeroVirtual test app (swift/TestHost), which needs no
99
# backend.
1010
# Path-filtered to the app and the UI layer: macOS runners are slow to start.
11+
# Within that, the changes job picks the jobs a change needs: the screenshots
12+
# for zbugs, the virtual list suites for the test app (swift/TestHost).
1113

1214
on:
1315
push:
@@ -38,8 +40,49 @@ permissions:
3840
contents: read
3941

4042
jobs:
43+
changes:
44+
name: Changed paths
45+
runs-on: ubuntu-latest
46+
outputs:
47+
zbugs: ${{ steps.paths.outputs.zbugs }}
48+
testhost: ${{ steps.paths.outputs.testhost }}
49+
steps:
50+
- uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0
51+
with:
52+
fetch-depth: 0
53+
persist-credentials: false
54+
- id: paths
55+
env:
56+
EVENT: ${{ github.event_name }}
57+
PR_BASE: ${{ github.event.pull_request.base.sha }}
58+
BEFORE: ${{ github.event.before }}
59+
run: |
60+
case "$EVENT" in
61+
pull_request) base="$(git merge-base "$PR_BASE" HEAD || true)" ;;
62+
push) base="$BEFORE" ;;
63+
*) base='' ;;
64+
esac
65+
# A manual run, a new branch, or a force push runs everything.
66+
if [ -n "$base" ] && git cat-file -e "$base^{commit}" 2>/dev/null; then
67+
files="$(git diff --name-only "$base" HEAD)"
68+
else
69+
files=all
70+
fi
71+
shared='^(swift/Sources/(ZeroUI|ZeroVirtual|ZeroTesting)/|\.github/workflows/zbugs-screenshots\.yml$)'
72+
needs() {
73+
if [ "$files" = all ] || grep -qE "$1|$shared" <<<"$files"; then
74+
echo true
75+
else
76+
echo false
77+
fi
78+
}
79+
echo "zbugs=$(needs '^apps/zbugs-swift/')" >> "$GITHUB_OUTPUT"
80+
echo "testhost=$(needs '^swift/TestHost/')" >> "$GITHUB_OUTPUT"
81+
4182
screenshots:
4283
name: zbugs screenshots (iPhone, iPad, and macOS)
84+
needs: changes
85+
if: needs.changes.outputs.zbugs == 'true'
4386
runs-on: macos-26
4487
timeout-minutes: 60
4588
steps:
@@ -153,6 +196,8 @@ jobs:
153196
# Each platform and suite in its own job: together they outrun a job's
154197
# timeout (the frame probe's scripts drive minutes of scrolling).
155198
name: Virtual list UI tests (${{ matrix.platform }}, ${{ matrix.suite }})
199+
needs: changes
200+
if: needs.changes.outputs.testhost == 'true'
156201
runs-on: macos-26
157202
timeout-minutes: 45
158203
strategy:

‎apps/zbugs-swift/App/UITests/ScreenshotTests.swift‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,15 @@ final class ScreenshotTests: XCTestCase {
3737
settle()
3838
snapshot(app, "1 Issues")
3939

40-
// An issue with its comments.
40+
// An issue with its comments. A tap while the list is still taking in
41+
// the sync can land on a row as it's replaced, so tap again if the issue
42+
// hasn't opened.
4143
press(firstIssue)
42-
if expect(app, app.buttons["Comment"], "the issue's comment button") {
44+
let comment = app.buttons["Comment"]
45+
if !comment.waitForExistence(timeout: 10), firstIssue.isHittable {
46+
press(firstIssue)
47+
}
48+
if expect(app, comment, "the issue's comment button") {
4349
settle()
4450
snapshot(app, "2 Issue")
4551
}

‎packages/zero-swift-cli/README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@ truth, and this tool generates what the Swift client needs from them.
66

77
```sh
88
npm install --save-dev zero-swift
9+
npx zero-swift --help
910
```
1011

1112
It needs Node 22.18 or later, which loads TypeScript files directly. It also

‎packages/zero-swift-cli/src/bin.ts‎

Lines changed: 10 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,12 @@
11
#!/usr/bin/env node
2-
import {main} from './cli.ts';
2+
import {main, UsageError} from './cli.ts';
33

4-
process.exitCode = await main(process.argv.slice(2));
4+
try {
5+
process.exitCode = await main(process.argv.slice(2));
6+
} catch (e) {
7+
if (!(e instanceof UsageError)) {
8+
throw e;
9+
}
10+
process.stderr.write(`zero-swift: ${e.message}\n`);
11+
process.exitCode = 1;
12+
}

‎packages/zero-swift-cli/src/cli.ts‎

Lines changed: 64 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -26,16 +26,44 @@ import type {Schema} from '../../zero-types/src/schema.ts';
2626
import {generate, type Config} from './generate.ts';
2727
import {readCustomTypes} from './types.ts';
2828

29+
const usage = `Usage:
30+
zero-swift codegen [--schema schema.ts] [--out Schema.swift]
31+
[--config zero-swift.json] [--check]
32+
zero-swift drift record [--samples drift-samples.ts]
33+
[--out drift-vectors.json] [--check]
34+
35+
codegen generates Swift from the app's TypeScript schema. Options override
36+
zero-swift.json, which is read when present.
37+
drift record runs the app's TS queries and mutators on samples and records
38+
what they did, for the Swift drift test.
39+
--check fails when the output is out of date instead of writing it.
40+
41+
Docs: https://github.com/wevm/zero/tree/main/packages/zero-swift-cli
42+
`;
43+
44+
/** A mistake in how the CLI was run: printed without a stack. */
45+
export class UsageError extends Error {}
46+
47+
function mustExist(path: string, message: string) {
48+
if (!existsSync(path)) {
49+
throw new UsageError(message);
50+
}
51+
}
52+
2953
export async function codegenFile(config: Config, cwd: string) {
3054
const schemaPath = resolve(cwd, config.schema ?? 'schema.ts');
55+
mustExist(
56+
schemaPath,
57+
`Schema ${relative(cwd, schemaPath)} doesn't exist. Pass --schema, or set "schema" in zero-swift.json.`,
58+
);
3159
const exportName = config.export ?? 'schema';
3260
const module = (await import(pathToFileURL(schemaPath).href)) as Record<
3361
string,
3462
unknown
3563
>;
3664
const schema = module[exportName] as Schema | undefined;
3765
if (!schema || typeof schema !== 'object' || !('tables' in schema)) {
38-
throw new Error(
66+
throw new UsageError(
3967
`${schemaPath} does not export a schema as "${exportName}"`,
4068
);
4169
}
@@ -49,26 +77,41 @@ export async function codegenFile(config: Config, cwd: string) {
4977
}
5078

5179
export async function main(argv: string[]): Promise<number> {
52-
const {values, positionals} = parseArgs({
53-
args: argv,
54-
allowPositionals: true,
55-
options: {
56-
schema: {type: 'string'},
57-
out: {type: 'string'},
58-
config: {type: 'string'},
59-
samples: {type: 'string'},
60-
check: {type: 'boolean'},
61-
},
62-
});
80+
let parsed;
81+
try {
82+
parsed = parseArgs({
83+
args: argv,
84+
allowPositionals: true,
85+
options: {
86+
schema: {type: 'string'},
87+
out: {type: 'string'},
88+
config: {type: 'string'},
89+
samples: {type: 'string'},
90+
check: {type: 'boolean'},
91+
help: {type: 'boolean', short: 'h'},
92+
},
93+
});
94+
} catch (e) {
95+
process.stderr.write(`${(e as Error).message}\n\n${usage}`);
96+
return 2;
97+
}
98+
const {values, positionals} = parsed;
6399
const [command = 'codegen', subcommand] = positionals;
100+
if (values.help || command === 'help') {
101+
process.stdout.write(usage);
102+
return 0;
103+
}
64104
const cwd = process.cwd();
65105
if (command === 'drift' && subcommand === 'record') {
106+
const samples = resolve(cwd, values.samples ?? 'drift-samples.ts');
107+
mustExist(
108+
samples,
109+
`Samples file ${relative(cwd, samples)} doesn't exist. Pass --samples.`,
110+
);
66111
// Loaded here: the recorder runs the app's `@rocicorp/zero`, which
67112
// codegen does not need.
68113
const {recordDriftFile} = await import('./drift.ts');
69-
const vectors = await recordDriftFile(
70-
resolve(cwd, values.samples ?? 'drift-samples.ts'),
71-
);
114+
const vectors = await recordDriftFile(samples);
72115
return writeOutput(
73116
resolve(cwd, values.out ?? 'drift-vectors.json'),
74117
vectors,
@@ -77,14 +120,19 @@ export async function main(argv: string[]): Promise<number> {
77120
);
78121
}
79122
if (command !== 'codegen') {
80-
process.stderr.write(`Unknown command "${positionals.join(' ')}"\n`);
123+
process.stderr.write(
124+
`Unknown command "${positionals.join(' ')}"\n\n${usage}`,
125+
);
81126
return 2;
82127
}
83128
const configPath =
84129
values.config ??
85130
(existsSync(resolve(cwd, 'zero-swift.json'))
86131
? 'zero-swift.json'
87132
: undefined);
133+
if (configPath) {
134+
mustExist(resolve(cwd, configPath), `Config ${configPath} doesn't exist.`);
135+
}
88136
const fileConfig = configPath
89137
? (JSON.parse(readFileSync(resolve(cwd, configPath), 'utf8')) as Config)
90138
: {};

‎packages/zero-swift-cli/src/codegen.test.ts‎

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
import {readFileSync, writeFileSync} from 'node:fs';
22
import {fileURLToPath} from 'node:url';
33
import {expect, test} from 'vitest';
4-
import {codegenFile} from './cli.ts';
4+
import {codegenFile, main, UsageError} from './cli.ts';
55
import type {Config} from './generate.ts';
66

77
const repo = fileURLToPath(new URL('../../../', import.meta.url));
@@ -94,3 +94,11 @@ for (const {name, dir = '', config} of fixtures) {
9494
expect(swift).toBe(readFileSync(path, 'utf8'));
9595
});
9696
}
97+
98+
test('a missing schema is a usage error, not a crash', async () => {
99+
await expect(main(['--schema', 'no-such-schema.ts'])).rejects.toThrow(
100+
new UsageError(
101+
`Schema no-such-schema.ts doesn't exist. Pass --schema, or set "schema" in zero-swift.json.`,
102+
),
103+
);
104+
});

‎swift/README.md‎

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ targets: [
3030
.target(name: "App", dependencies: [
3131
.product(name: "Zero", package: "zero-swift"),
3232
.product(name: "ZeroUI", package: "zero-swift"), // SwiftUI, UIKit, AppKit
33+
.product(name: "ZeroVirtual", package: "zero-swift"), // long lists
3334
]),
3435
.testTarget(name: "AppTests", dependencies: [
3536
.product(name: "ZeroTesting", package: "zero-swift"),
@@ -68,6 +69,43 @@ struct IssueList: View {
6869
zero.mutate(IssueMutators.create(.init(id: newID(), title: "Crash on launch", created: now)))
6970
```
7071

72+
## Long lists
73+
74+
`ZeroVirtual` is `@rocicorp/zero-virtual` for Swift. A `ZeroVirtualizer`
75+
pages a list's rows in from your queries as it scrolls, runs at most three
76+
queries, and drops rows far from the screen. It can open at a row
77+
(`permalinkID`) or where the list was (`scrollState`). What's on screen stays
78+
put as pages load, rows go, and rows change height.
79+
80+
```swift
81+
import ZeroVirtual
82+
83+
let issues = ZeroVirtualizer(
84+
zero: zero, listContext: filter, estimatedRowHeight: 56,
85+
rowKey: \.id, toStartRow: { IssueStart(id: $0.id, modified: $0.modified) },
86+
pageQuery: { page, filter in IssueQueries.page(filter, page) },
87+
rowQuery: { row, filter in IssueQueries.byID(filter, row.id) })
88+
89+
// SwiftUI
90+
VirtualList(issues, selection: $selected) { item, isSelected in
91+
IssueRow(issue: item.row, highlighted: isSelected)
92+
} header: {
93+
FilterBar() // header and footer are optional
94+
}
95+
96+
// UIKit or AppKit: rows are your own views, reused as they scroll.
97+
let list = VirtualListView(issues) { item, isSelected, reusing in
98+
let row = reusing as? IssueRowView ?? IssueRowView()
99+
row.show(item.row, selected: isSelected)
100+
return row
101+
}
102+
list.onSelect = { id in open(id) }
103+
```
104+
105+
A tap selects a row, and on macOS so do the arrow keys and j and k. The doc
106+
comments on `ZeroVirtualizer`, `VirtualList`, and `VirtualListView` cover the
107+
rest, and `apps/zbugs-swift` uses all three.
108+
71109
## Documentation
72110

73111
The DocC catalogs in `Sources/*/*.docc` cover the following. Open them in

‎swift/Sources/ZeroVirtual/ZeroVirtualizer.swift‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ import Zero
1717
/// rowQuery: { row, filter in Queries.issueByID(filter, row.id) })
1818
/// ```
1919
///
20-
/// A scroll view adapter (`VirtualList`, `VirtualTableAdapter`) attaches
20+
/// A scroll view adapter (`VirtualList`, `VirtualListView`) attaches
2121
/// itself and renders `snapshot`.
2222
@Observable
2323
@MainActor

0 commit comments

Comments
 (0)