End-to-end tests for the macOS File Provider implementation of the Nextcloud desktop client, run against real Nextcloud servers deployed on demand as local Docker containers.
A test here drives the signed, distribution build of the client installed at
/Applications/Nextcloud.app, configures it through its own command line options, and then compares
two independent accounts of the same state:
- what the server says, read over WebDAV and OCS with Rainmaker,
- what the client says, read from its File Provider domain under
~/Library/CloudStoragewithstatandreaddir.
Deliberately out of scope: the classic synchronisation folders, everything which is only reachable through a window or a Finder menu, and every platform other than macOS.
Since version 34 the client is App Sandboxed, so its configuration, logs and caches live inside
~/Library/Containers/com.nextcloud.desktopclient/Data/ rather than where an unsandboxed application
would keep them. A path outside that container is not writable by the client at all, which is why the
harness never passes it one.
The test process cannot use NSFileProviderManager — the framework reserves it for the provider's
own app and its sibling extensions — so there is no waitForChanges, no signalEnumerator and no
manual scheduling. Every wait is a condition poll against a deadline, and materialization state is
read from the SF_DATALESS flag in st_flags.
Most suites here are written by hand, and each of them varies one thing — which server it talks to — while pinning everything else to whatever the author happened to pick: a file, at the root, already materialized, of one size. Those choices are invisible, so what they leave untested is invisible too.
Sources/ScenarioMatrix/ makes them explicit. It is an axis model — what an item can be, what state
it can be in, where it lives, what is done to it, where the change originates — together with the
rules that prune combinations which cannot exist, such as editing the bytes of a file whose content
was never downloaded. It enumerates cells; it knows nothing about how any of them is established,
and it has no imports at all, not even Foundation, so it cannot acquire that knowledge by accident.
Support/ScenarioWorld.swift is the half it does not have: it puts a real client and a real server
into the state a cell describes, and then checks that it did. Support/ScenarioOracle.swift judges
what happens next, and declines by name the clauses it cannot observe — an assertion which cannot
fail is worse than a missing one, because it reports as coverage.
A generated suite has a failure mode a hand-written one does not: the set of cases can shrink to
nothing without a single test failing. Suites/ScenarioSelectionTests.swift therefore pins the exact
rows each generated suite runs, and counts the ones it does not run yet.
The client under test is installed at /Applications/Nextcloud.app from its signed disk image and
launched once by hand, so that Gatekeeper is satisfied and the File Provider extension is registered
and approved. The location is not configurable: app extensions are not reliably loaded from anywhere
else, and a copy elsewhere would not imitate a real installation.
Also needed:
-
Docker Desktop or OrbStack, running.
-
Full Disk Access for whichever application starts the run — in practice the terminal — followed by a restart of it, because a privacy grant only applies to a newly launched process. This is not optional, and it is not merely about reading files: without it macOS asks whether the application may access files managed by Nextcloud, suppresses the repeats, and then answers later reads with
Operation not permittedand no dialog at all. With it, a domain is read silently and a run needs nobody at the keyboard. Preflight checks it by reading a genuinely protected path, soswift run tests doctorwill tell you.The service is
kTCCServiceFileProviderDomain, which is not Full Disk Access and not the "Files and Folders" group in System Settings — those are theSystemPolicy*services. Its grant is keyed by a triple: the service, the reading application, and the provider domain. A provider reading its own domains is granted automatically because the bundles match, which is why the client never prompts and this harness does: the reader is a different bundle, inherently.~/Library/CloudStorageitself lists without any grant — protection resolves path to domain, and the parent resolves to no domain — which is why an early version of this check passed on a machine that then failed every test.On macOS 27 and later this grant also covers the client's own application data, which that release began guarding separately with
kTCCServiceSystemPolicyAppDataDetailedfor a hardcoded list of applications in/usr/libexec/sandboxd— a list naming bothcom.nextcloud.desktopclientand its group containerNKUJUXUJ3B/com.nextcloud.desktopclient. Those are where this suite readsnextcloud.cfgand the extension's per-domain logs, so a refusal would cost it the configuration and all of its evidence. Measured on 27.0 (26A428): Full Disk Access alone satisfies it, and no second grant is asked for. It is checked separately anyway, because the two were not known to be one grant and a future release may separate them again.The refusal has a shape worth knowing, because it is not the one a missing file has: the container can still be looked up and its directories still appear, while every read of anything inside them answers
Operation not permitted. A check asking whether a path exists passes throughout.swift run tests doctorreports this separately, as Client data.The same release moved the per-user privacy database out of
~/Library/Application Support/com.apple.TCC/into/private/var/containers/Data/ProtectedSystem/, where Full Disk Access does not reach it either. It had been this project's Full Disk Access probe, and when it vanished the check reported a machine that held the grant as one that did not. Probes now report absent separately from refused, and there is more than one of them.A read of a domain has four outcomes, and telling them apart is the whole job:
Granted the read succeeds Explicitly denied NSCocoaErrorDomain257 with an underlyingEPERM, loudlyNever asked, and the reader cannot be prompted the read parks indefinitely Granted but the provider has not populated or authenticated yet an empty listing, legitimately The third is the dangerous one and it is the default for a fresh domain under an unattended runner: a client with no
NSFileProviderDomainUsageDescriptioncannot be shown the prompt, so the access request is never resolved and the read neither returns nor fails. That is why every wait in this harness carries its own deadline and abandons the thread rather than waiting on it — seeDeadline. An empty listing is not a permission problem and is a state to wait on.There is no non-interactive way to pre-grant this:
tccutilhas only aresetverb, and the service does not appear in the configuration-profile surface where PPPC payloads are defined. Full Disk Access is what makes unattended runs work in practice — with it granted to the terminal, this suite creates over a hundred fresh domains in a run and is never prompted once, which is also the evidence that the grant covers every domain rather than being consulted per domain.That makes the manual grant a deployment requirement rather than a convenience, and the reason is structural. A SwiftPM test bundle has no
Info.plistat all, so it cannot declareNSFileProviderDomainUsageDescription; a reader which cannot declare it cannot be prompted; and a request which cannot be prompted is never resolved. The obvious remedy — adding the key — is not reachable without changing how the bundle is built. Somebody granting Full Disk Access by hand, once, is what makes this suite runnable at all, and it is invisible from the code. -
Enough free disk space for container images and materialized fixtures.
A developer machine is explicitly supported for writing and debugging these tests. The destructive steps print what they found and ask before removing anything, and they keep a copy of the existing configuration in the artifacts directory.
For long runs, give the suite a machine of its own — a dedicated Mac, a dedicated macOS user,
or a macOS virtual machine — with auto-login, no screen lock or screen saver, Focus switched on,
caffeinate -dimsu for the duration of the run, Spotlight indexing of the domain switched off, and
no personal Nextcloud account on that login. Keep Finder away from the domain: it materializes files
to draw previews. In a virtual machine, keep Docker inside the same machine so that the addresses the
client sees are the ones the tests use.
The full matrix is not run by continuous integration and is not meant to be. It needs Docker, a
logged-in graphical session, the signed client installed at /Applications/Nextcloud.app and a
privacy grant given by hand, and it is destructive to whatever account is configured on the machine
it runs on. Hosted macOS runners have none of that. So a full run is something a person starts, on
their own machine or in a virtual machine, and the only thing continuous integration does here is
build the package and run the hermetic tests.
Hermetic unit tests of the harness itself, which need no server, no client and no Docker:
swift testThe live suites report themselves as skipped there. They are run through the runner, which deploys the servers, resets the client, runs one test process against the whole matrix, and tears everything down again:
swift run testsswift run tests --tags latest --filter DomainLifecycleTests--filter is a regular expression matched against the name of the test type, such as
ConflictTests, not against the name a suite is given for display. A filter which matches nothing
fails the run rather than reporting a clean pass over an empty suite.
swift run tests doctorswift run tests resetdoctor changes nothing: it reports whether the machine is ready and what a run would remove.
prepare and teardown are the two halves of a run without the test process in between, for
debugging from Xcode — see Running from Xcode.
The matrix is not written down anywhere. latest is deployed first and asked which release it is,
and the major before that becomes the other half — a server reporting 34.0.3 gives a matrix of
latest and 33. That matches the support policy of one current release plus its predecessor, and
it means no file in this repository has to be edited when a server release ships. The day a 35 is
published, the same code tests 35 and 34.
Both halves are recorded as they were found: a server's descriptor carries the version it reported
alongside the tag it was deployed under, because latest is a moving target and two runs weeks apart
under the same tag may well have tested different releases.
Useful options of the run: --tags names the releases explicitly and switches the derivation off, --with-push deploys every release a second
time with the High Performance Backend for Files, --filter is passed on to the test process,
--keep-containers leaves the servers up for investigating a failure, --no-client-reset leaves the
desktop client on this machine untouched (for suites which need no client, such as the server
provisioning ones), --allow-development-client accepts a client the system policy rejects, and
--artifacts chooses where everything is collected.
The subject is normally the notarized build a user installs, and preflight refuses anything else. A
development build has to be testable too — that is how a fix is verified before it is released — so
--allow-development-client accepts one, and the report says so in the words "not the build a user
would install" rather than quietly passing.
The environment the runner passes to the test process, should a run need to be reproduced by hand:
| Variable | Meaning |
|---|---|
FPT_MATRIX |
JSON array describing the servers under test, including the version each reported |
FPT_MATRIX_FILE |
A file holding that same JSON, used when FPT_MATRIX is unset |
FPT_ARTIFACTS_DIR |
Where logs, attachments and reports are collected. Defaults to the directory of FPT_MATRIX_FILE |
FPT_TIMEOUT_SCALE |
Factor applied to every timeout, for slow machines |
FPT_ALLOW_DESTRUCTIVE |
Approves the removal of an existing client configuration up front |
FPT_ALLOW_UNNOTARIZED_CLIENT |
Accepts a client the system policy rejects, such as a development build |
FPT_REPETITIONS |
How many times a characterisation suite repeats each cell. Those suites skip themselves without it |
Two suites measure rather than assert, and they exist because the contract suites found something that does not happen every time. A test which fails intermittently tells you a defect is real and nothing else; these turn it into a proportion, which is the number a bug report needs and the number a fix has to move.
They repeat one cell many times in a single clean room and report how often the thing propagated, separating late from lost — a change which arrives at three minutes is a delay, one which never arrives is a divergence the user cannot see, and the two live in different code. They assert almost nothing on purpose: a characterisation suite which fails is one nobody runs twice, and the failure is already asserted in the contract suite beside it.
FPT_REPETITIONS=10 FPT_ALLOW_DESTRUCTIVE=1 swift run tests --tags latest --filter MovePropagationRateRead every number they produce as a rate within one session. Building a clean room costs about
eighty seconds against a trial's thirty, so the trials share a room, an account and a client which has
been running for a while — a cell which fails repeatedly may be reporting one bad state rather than
many independent failures. DeletionPropagationRateTests learned that the hard way and says so at
length.
Each carries its own control, and the move suite carries two. It moves every kind of item under a name which collides with a sibling by case and under an ordinary one, and separately into a folder the client has opened and into one it has not, holding everything else fixed — so the comparison is inside one run rather than across two. The destination arm is the reason the first answer is known to have been wrong: it looked like the unopened folder until the encodings were put on their own axis and forty unencoded moves into one arrived without a single loss.
A trial which loses its item asks where the item went, by looking up the name it was given, the four names macOS produces when it has to disambiguate, and the item's old path. It asks by path rather than by listing, because listing the destination is what stops it being the unopened folder the cell pinned, and it leaves the lost item in place so that there is something to find. The two containers are listed once, after the last trial of a cell, where a listing can no longer change what any trial measured. Everything that finds is attached to the run beside the rate.
Worth the setup, because the reason to run a suite from Xcode is the debugger, and a breakpoint inside a test which is waiting on a File Provider is worth a great deal when the client does something nobody predicted. The client under test is a separate, signed application either way, so nothing about debugging the tests changes what is being tested.
Xcode cannot deploy the containers, so that half is done first and left running:
swift run tests prepare --tags latest --allow-development-clientThat resets the client, deploys the servers, writes Artifacts/session/matrix.json and prints the
three variables to set. Open Package.swift in Xcode, then Product, Scheme, Edit Scheme, Test,
Arguments, and add:
| Variable | Value |
|---|---|
FPT_MATRIX_FILE |
the absolute path prepare printed |
FPT_ALLOW_DESTRUCTIVE |
1 |
FPT_ALLOW_UNNOTARIZED_CLIENT |
1 |
None of the three change between sessions. The ports do — they are assigned when the containers
start — which is exactly why the scheme points at a file instead of carrying the matrix itself:
prepare rewrites the file and the scheme keeps working.
Two settings which are not optional:
- Full Disk Access for Xcode, followed by quitting and reopening it. A privacy grant applies only
to a newly launched process, and without it reads inside a domain are refused with
NSCocoaErrorDomain257. - Parallel execution off, in the same scheme under Test, Options. There is one desktop client and one File Provider domain on the machine, so the suites cannot run at once. A clean room refuses to be built while another one stands and says so, rather than letting the two corrupt each other.
Then run the tests as usual. Afterwards:
swift run tests teardownA prepared session is otherwise indistinguishable from a run: the same clean rooms, the same
per-test users, the same artifacts. What it does not do is delete the containers, which is what
teardown is for and why prepare writes down what it deployed.
Across four servers on one machine, latest being 34.0.3 and the derived predecessor 33.0.8. The
latest tag has since moved to 35.0.0, so these figures describe the releases named here rather than
whatever latest resolves to today — which is why a run records the version each server reported
and not the tag it was asked for:
| Measurement | Plain | With the High Performance Backend |
|---|---|---|
| Server to client propagation | 9.0–29.0 s | 0.83–0.87 s |
| Client to server propagation | 0.38–1.01 s | 0.26–0.45 s, once 11.1 s |
| Materialization of 8 MiB | 0.65–0.79 s | 0.65–0.73 s |
| Building one clean room | 7–15 s | 7–15 s |
The two releases behave the same. Nothing measured here separates 33.0.8 from 34.0.3 beyond the noise the numbers already carry, which is the answer one wants from the older half of the matrix.
The one slow number is the client polling for server-side changes, and notify_push removes it
almost entirely — a factor of thirty on the direction which dominates the run time. Both
configurations are worth testing, because both are deployed in the wild; --with-push adds the second
one. Waits for anything the server originates are given three minutes, which is generous against the
29 s baseline rather than optimistic.
That baseline does not behave like a delay drawn from a polling interval. Five measurements have produced 9.0, 29.0, 29.0, 9.2 and 28.8 seconds: two clusters, not a spread. A change landing at a uniformly random point of a fixed interval would fill the range between them, so something is quantizing it, and the earlier guess that the measurement is simply phase-locked to a clean room taking a constant time does not explain two attractors. It is recorded here as an observation rather than explained, because the explanation is not known. What can be said is that the figure is a property of this suite's timing as much as of the client, and it should be read as an upper bound rather than as a delay a user would typically see.
One measurement is unexplained and is written down so that a second sighting is recognisable rather
than surprising: on latest+push, client to server propagation was 11.1 s, against 0.45 s for
33+push in the same run and 0.26–0.44 s for latest+push in every earlier one. Uploading should
not depend on the push backend at all, since that carries notifications in the other direction. Four
containers and their Redis sidecars were up at once, so contention is the innocent reading — but it
does not account for 33+push being unaffected under the same load. One sample, so it is not yet a
finding.
An empty file is worth one note: it keeps the SF_DATALESS flag after being read, because there was
never anything to fetch. Only a file with content proves that reading it brought the content down.
Eviction is asynchronous and does not settle all at once: the dataless flag has been observed to flip while the reported size was still catching up. All three properties of a placeholder are therefore awaited together rather than asserted the moment the flag appears.
Measured on latest, with and without push. The two sides of a synchronised file do not spell names
the same way, and they are consistent about it:
| Direction | Written as | Ends up as |
|---|---|---|
| Server to client | Übung.txt precomposed (U+00DC) |
decomposed (U+0055 U+0308) |
| Client to server | éclair.txt decomposed (U+0065 U+0301) |
precomposed (U+00E9) |
One policy explains both: the wire format is precomposed, the local file system hands out decomposed. The round trip is stable and lossless — a name written decomposed is stored precomposed and read back decomposed, and it is the same file throughout.
So this is asserted rather than tolerated. Recording it as a known issue would have been the wrong call: there is nothing here waiting to be fixed, and the assertions catch the regression that would actually cost users something — a client putting a decomposed name on the wire, where every Windows and Linux client of the same account would see a file it has no reason to consider the same one. Each direction therefore checks three things: that the two spellings are canonically the same name, that the spelling is the expected normalization form, and that the item exists exactly once on each side. The last one is checked on the server after the name has been enumerated locally, because that is when a client which compares names by their bytes decides the file is missing and uploads a duplicate.
Names which cost nothing either way, and are in the suite to prove it: an emoji with a variation selector, a name with no extension, and a 180-character name.
Each run writes to Artifacts/<timestamp>/. The directory is deliberately not hidden — a run leaves
real evidence behind and real disk behind it, and neither should be invisible. It is git-ignored, and
that matters for more than tidiness: a run's logs describe client defects which have not been
reported yet, and this repository is public.
The directory holds:
index.html— the run's front page, and the one artifact meant to be read rather than parsed. One row per cell, grouped by quadrant, each saying in a sentence what the cell did and how it went, with the technical identifier underneath as a footnote and a link into that cell's logs. Outcomes have a legend, because no measurement means nothing to anyone who did not write it. Above the rows is a heat map — one square per cell, the whole run on one screen, grouped and ordered exactly as the sections below are, so a column of red says an axis is broken in a way no amount of scrolling would have said. Each square links to its row, and the four outcomes differ in fill, stripe and border as well as in colour, so the pattern survives a greyscale print. Beside it a filter narrows both the map and the rows to one outcome; a quadrant none of whose cells match collapses to its heading and a count rather than disappearing, because a section which is not on the page cannot be told from a quadrant which never ran — and under a filter those headings reading zero are the finding. The line beside the control says how much of the run is on screen. Without JavaScript the control does nothing and every cell stays visible. What a cell saw but was not entitled to assert — which name a colliding pair settled on, which version won a conflict — appears beneath its row, on passing cells as much as failing ones, and what the run could not judge at all is listed once at the end under Not judged — a cell above may have passed without any of those clauses being checked, which is exactly the conclusion a green row would otherwise invite.reports/— a drafted bug report per defect. See When a test finds something.run.json— what the run was: the client, the servers and the releases they actually reported, the preflight checks, the flags it was started with, and the machine's time zone. The zone is not incidental — the File Provider extension timestamps its log in local time and never says which, so reading that log later is impossible without it.events.jsonl— everything the test process recorded, one event per line. The only artifact which says which case of a parameterized test failed and when, which is what ties a failure to the clean room it happened in. Written by an undocumented option, so--no-event-streamturns it off and a report degrades to the xUnit file without it.results-swift-testing.xml— JUnit. One entry per test function with its total duration, which is thinner than it sounds: a parameterized test is a single entry, and neither the server release nor the other arguments appear in it. It answers "did the run pass", not "which case failed".attachments/— diagnostics bundles, recorded measurements, and observations: the things a cell saw and is not entitled to assert. A clause whose specification permits more than one result is recorded rather than judged, and recording it in a terminal would mean two runs a month apart could not be compared.clean-rooms/<user>/room.json— which test the room belonged to, which cell of the matrix it was, which server it ran against, and when it began and ended. A room's directory is named after the Nextcloud user it created, and that name has no relation to the name of the test function, so without this nothing on disk connects a failure to the logs which might explain it. The window is how a failure is placed: rooms never overlap, so a room owns every moment from its own start to the next room's. Not to its own end — an error thrown out of a test is recorded after its room has been torn down, so a window closed atendedAtplaces exactly the failures worth placing nowhere.clean-rooms/<user>/client-logs/— the desktop client's own log for that test: the account and the domain lifecycle.clean-rooms/<user>/extension-logs/— the File Provider extension's log for that test, which is the only account of what the system asked the provider to do and what it answered. Debug level is switched on for every clean room, because the failures worth having it for are the ones nobody thought to enable it for beforehand. A domain's log outlives its test by no more than the next client start, so it is taken at teardown or not at all.diagnostics/<user>/— for a failing test: the client log, the server log, the File Provider defaults, the relevant part of the unified log, and the enumeration ledger.metrics.md— the propagation and materialization timings, which JUnit cannot express.client-configuration-backup/— the configuration which was on the machine before the run.
The enumeration ledger deserves a note. Enumerating a directory inside a domain is not a passive observation: the first read of a container is what drives the extension's enumerator for it. The harness therefore never walks a tree, records every directory a test did enumerate, and lists only those in a diagnostics bundle — collecting diagnostics must not change the state being diagnosed.
Headless account setup was broken in released client 34.0.3 in two ways: command-line setup was
refused whenever File Provider mode was on, and --apppassword was never persisted, so the account
could never authenticate. Both are fixed in
nextcloud/desktop#10726; this suite was brought up
against a development build carrying that fix, with --allow-development-client.
Two behaviours of the fixed client shape the harness and are worth knowing before reading
CleanRoom:
-
Account setup is a one-shot provisioning run. The client starts, writes the account, and exits — without creating a File Provider domain, because the settings controller does not know the account yet and logs
Account not found for user …at that point. An ordinary second launch turns the configured account into a domain. The clean room therefore launches twice, which is whatDesktopClient.provisionAccount(_:timeout:)and the plain launch after it are for. -
Reading a domain can block in the kernel indefinitely. An
openorreaddirunder~/Library/CloudStoragewaits for the provider to answer, and a provider which is starting, wedged or unauthenticated never does. Such a call is not cancellable, so every wait gives each attempt its own deadline and abandons it — seeDeadlineandWaiter.attemptTimeout. Without that, a wait never reaches its own timeout and the whole run hangs. -
Synchronisation can be blocked from outside the client. Setting the
blockSyncboolean in the File Provider extension's own preferences stops it performing any network input or output: every request the system makes of it which would need the server is refused withNSFileProviderErrorServerUnreachable, in both directions.ClientSynchronisationwrites it with thedefaultstool, which reaches the extension's sandbox container where aUserDefaultssuite opened from this process would not.
A failing test here is usually a client defect, and the step between noticing that and filing it upstream is mostly transcription: which client, which server, which macOS, what was asserted, what happened instead. That part is done for you.
A failed run drafts a report per defect into Artifacts/<run>/reports/, and swift run tests report
does the same on demand for any run — name it, or leave it out for the most recent one. The document
follows the bug template of nextcloud/desktop field by field,
so it is pasted rather than rewritten: what the defect is, how to reproduce it, what was expected,
what happened instead, which files were affected, the environment, and an excerpt of the File
Provider extension's own log around the failure.
One report covers one defect rather than one failure. Every test runs against every server in the matrix, so a client defect arrives as four failures differing only in a port number and the room they happened in — and four near-identical documents describing one problem are worse than one. Failures are grouped by the test, the expectation within it and what that expectation said once the parts which vary per run are elided. Two expectations failing, or one expectation failing for two different reasons, stay two reports.
Grouping is also what makes the most useful line in a first report possible, because no single
failure knows it: "Affected: latest, latest+push. Not affected: 33, 33+push." A maintainer
reading that has a bisection already started.
The comparison is worth reading even when it is empty. A defect in the client is usually selective — it tracks something the matrix varies, which is why the matrix exists. One which tracks nothing, and fails identically on every server, is as often a fault in the suite doing the observing, so a report in that shape carries a line saying to rule the suite out first. That is not a hunch: this project's first sixteen failures were uniform across four servers and every one of them was a single broken accessor in the harness. The shape said so before any of the messages did.
An existing report is never overwritten without --force, because the point of a draft is that
somebody edits it. A failure the suite already knows about is skipped: it is being watched on purpose
and is not news.
Every section is filled. There are no blanks to come back to and no headings standing over nothing, because a document which is mostly a list of chores teaches its reader to skim.
The description is quoted rather than composed. An expectation in this suite carries a sentence saying what must not happen — "the version written on the server was replaced by the one written locally, and nothing was kept of it" — written by a person when the test was written. That sentence is the description, and its first clause is the title, which makes writing a good comment beside an expectation the same act as writing a good bug report.
What a run cannot establish is simply absent rather than stubbed. A suspected cause belongs to whoever has read the client's source, and a guessed one in a generated document sends a maintainer down a path for no reason.
The Environment section is worth more than it looks. Alongside the versions it carries every preflight check the run made and a sentence on isolation — that tests run one at a time, and that the account, its domain and its server user existed for this test alone. That forecloses the first alternative explanation anybody reaches for. This project spent two runs eliminating its own harness before any of it was written down.
Nothing is filed for you. The command drafts; you finish it and post it. That is not politeness: the client's own contribution policy requires an issue to be in the contributor's words and forbids an agent opening one.
Reports live under Artifacts/, which is git-ignored, and the writer refuses to run anywhere the
repository would pick it up. A draft describes a defect nobody has reported yet and this repository
is public, so that check is made rather than assumed.
Once an issue exists, wrap the detecting test so the suite goes green while the defect stays covered:
withKnownIssue("nextcloud/desktop#12345") {
// the assertion which fails
}Swift Testing then reports expected issue did not occur when the client is fixed, which is how a fix upstream reaches this suite without anyone remembering to check.
Some behaviour only appears when the client and the server hold different versions of the same file at the same time, and arranging that is harder than it sounds: a client which is talking to its server does not stay out of date long enough to be caught. There are two ways to produce it, and they are not interchangeable.
| Tool | What it stops | Use it for |
|---|---|---|
ServerWorkspace.withServerPaused |
the whole server, for everybody | outages: the client cannot reach the server, and neither can this suite |
ServerWorkspace.withSynchronisationBlocked |
the whole client, both directions | a client which is up but deaf, on any macOS the suite supports |
SyncControl.pause / resume |
one item | conflicts |
Suspending the container cannot produce a conflict, because the remote half of the disagreement has to be created through the very server which is suspended.
Blocking the client can produce a divergence, but not a conflict test, and the difference cost this
project a wrong bug report. macOS asks a provider to detect a conflict only when an application asked
for that treatment: it pauses the item it has open and resumes with
NSFileManagerResumeSyncBehaviorAfterUploadWithFailOnConflict. Absent that, the documented behaviour
is preserveLocalChanges — the local version is uploaded and the server "may create a conflict copy,
or may automatically pick the winner". A test which merely arranges a divergence and waits is
measuring that default, and has no standing to call either permitted outcome a defect.
So the conflict suite goes through SyncControl, which takes the same route a document-based
application takes. It is per-item, addressed by URL, needs no provider identity and no change to the
client, and is the only way to ask for conflict detection at all. SyncControl.supportedControls
reads what the provider advertises through NSURLUbiquitousItemSupportedSyncControlsKey, because the
fail-on-conflict behaviour is only available where the provider says it is supported — a provider
which handles the error but never advertises the capability has written code no application may
reach. These calls are macOS 26 and newer.
A blocked client is dangerous in a way a suspended container is not. A frozen container announces
itself, because the next test cannot reach its server at all. A blocked client is silent: every later
test simply waits for something which is never going to happen. So the value is cleared on the way
out of the scope which set it, again when a clean room is built, and again by a full reset; a machine
left blocked is reported by swift run tests doctor and refused by preflight before a run starts.
A domain exists but cannot be read, or a dialog asks whether the application may access files
managed by Nextcloud. Full Disk Access is missing for the application which started the run. Grant
it, restart that application, and check with swift run tests doctor. Reads block while such a dialog
is unanswered and are refused outright once macOS stops asking, so this looks by turns like a hang and
like a permission bug.
A test finds demo content in a supposedly empty user. The skeleton directory is switched off per container after deployment. If a release ignores that, the user's root has to be emptied per user instead — see spike S8 below.
The client is running but no domain appears. Check attachments/, then the client log in
diagnostics/. The clean room reads that log itself and fails immediately when the client reports
that it gave up on the account, rather than waiting for a domain which will never arrive.
A domain directory outlives its test. The client reaps domains without an account when it next starts. Spike S7 establishes whether that holds up over hundreds of clean rooms.
Open spikes. These questions shape the design and are answered here as they are settled:
| # | Question | Status |
|---|---|---|
| S1 | Do the account options really produce a domain, and how quickly? | yes, after a second launch, in about ten seconds |
| S2 | Is File Provider mode on by default, or does a setting have to be pre-seeded? | on by default: macFileProviderModeEnabled=true in [General] of nextcloud.cfg, written by the client itself when it starts without a configuration |
| S3 | Do evictUbiquitousItem and startDownloadingUbiquitousItem work on these items? |
yes, both — a process outside the provider's application can evict a materialized item and can ask for one to be fetched without reading it |
| S4 | Does the Full Disk Access grant survive rebuilds of the test binary? | yes — the grant follows the application which starts the run, not the ad-hoc-signed test binary. It is also mandatory: see Machine setup |
| S5 | Is the client reset complete — does a second run start clean? | yes for the container state; the Keychain part is unproven, because the sandboxed client's items are not visible to the security tool |
| S6 | What is the baseline propagation latency, with and without push? | measured both ways — see What has been measured |
| S7 | Does the clean room hold up over hundreds of repetitions? | holding at roughly 116 rooms in one run across four servers, with no leaked user, domain or client process. Several hundred is still unproven |
| S8 | Does switching off the skeleton directory work on every supported release? | yes on both halves of the matrix, 34.0.3 and 33.0.8, verified by the server provisioning suite and again by the domain lifecycle one |
See LICENSE.
SwiftFormat is expected to be installed and is configured by .swiftformat. Before submitting a pull request, run it over the repository:
swiftformat .