End-to-end test guidelines
These guidelines are derived from patterns that caused flakiness in the Cypress end-to-end suite and the fixes that resolved them. Follow them when writing new tests or reviewing existing ones.
1. Poll for state; never assert immediately after a write
Backend writes (upload, trash, delete, update) are processed asynchronously by a
background queue. A fixed cy.wait(N) will pass on a fast machine and fail on a
slow one.
Bad
cy.get("[data-test=trash]").click();
cy.wait(500);
cy.request(urlApi).then(res =>
expect(res.body.fileIndexItems.some(...)).to.be.false // may be too early
);
Good — write a retry loop that checks the actual condition:
function waitUntilFileAbsent(filePath: string, index = 0, max = 15) {
cy.request(config.urlApiCollectionsFalse).then(response => {
const items: Array<{ filePath: string }> = response.body.fileIndexItems ?? [];
if (!items.some(i => i.filePath === filePath)) return;
cy.wait(1500);
if (++index < max) waitUntilFileAbsent(filePath, index, max);
else expect(true, `${filePath} should be absent`).to.be.false;
});
}
Use the same pattern for uploads (waitUntilBothIndexed) and for cache
propagation (waitUntilCollectionReady).
2. Check specific file paths, not total counts
Other suites leave files in the shared test folder. A count check (length >= 3)
passes with the wrong files or fails because extra files are present.
Bad
if (response.body.fileIndexItems.length >= 3) return;
Good
const allPresent = expectedPaths.every(fp =>
items.some(i => i.filePath === fp)
);
if (allPresent) return;
3. Use content-based selectors, not positional ones
eq(N) is fragile: the position of an element changes when other tests leave
items behind or when the rendering order shifts.
Bad
cy.get("[data-test=collections]").eq(1).click();
Good
cy.contains("[data-test=collections]", fileNameMp4).scrollIntoView().click();
4. Wait for buttons to become enabled before clicking
Modal action buttons (Move, Submit, etc.) are disabled until async state settles — a folder selection, a validation check, an API response. Clicking a disabled button is silently ignored and the test hangs.
Bad
cy.get("[data-test=btn-child_folder]").click();
cy.get("[data-test=modal-move-file-btn-default]").click(); // may still be disabled
Good
cy.get("[data-test=btn-child_folder]", { timeout: 15000 }).click();
cy.get("[data-test=modal-move-file-btn-default]")
.should("not.be.disabled")
.click();
5. Give async-loaded modal content a real timeout
Folder lists, collection members, and sidebar content are fetched from the API after the modal opens. The default Cypress retry timeout (4 s) is not enough on Windows CI where the machine is slower.
Use at least { timeout: 15000 } on any element that appears as the result of
an API call inside a modal:
cy.get("[data-test=parent]", { timeout: 15000 }).click();
cy.get("[data-test=collections]").should("have.length", 2);
6. Prefer DOM-based waits over cy.intercept for reads
cy.intercept on GET requests is unreliable across platforms: on Windows the
browser often serves the response from its own cache, so the network request
never fires and cy.wait("@alias") times out.
Unreliable on Windows
cy.intercept("GET", "**/api/info*").as("info");
cy.wait("@info"); // silently skipped when served from browser cache
Reliable on all platforms
// Assert the DOM element that depends on the data
cy.get("[data-test=collections]").should("have.length", 2);
// Or poll the API directly until the expected value appears
waitUntilCollectionReady(0);
Reserve cy.intercept + cy.wait for write operations (POST, DELETE,
PUT) where you need to confirm the server accepted the request before
proceeding.
7. Set up intercepts before triggering the action
An intercept registered after the click can miss the request if the browser dispatches it synchronously.
Bad
cy.get("[data-test=trash]").click();
cy.intercept("**/api/trash/move-to-trash").as("trash"); // too late
cy.wait("@trash");
Good
cy.intercept("**/api/trash/move-to-trash").as("trash");
cy.get("[data-test=trash]").click();
cy.wait("@trash");
8. Assert each contenteditable field's value before moving to the next
React's onBlur handler updates component state after each field is left. That
state change triggers a re-render. On Windows CI, Cypress types fast enough
that the re-render from field N can race with Cypress focusing field N+1,
wiping the just-typed value.
Assert the current field's text after blur to ensure React has settled before the next field is touched:
function typeInField(selector: string, value: string) {
cy.get(selector).focus();
cy.get(selector)
.type("{selectall}")
.type(value, { parseSpecialCharSequences: false });
cy.get(selector).blur();
cy.get(selector).should("have.text", value); // wait for React re-render
}
This is only necessary when filling multiple fields in sequence. Single-field edits are not affected.
9. Clear sessionStorage before reloading to verify persistence
Starsky's FileListCache stores index responses in sessionStorage with a
3-minute TTL. A reload without clearing the cache can return stale data,
making a persistence check pass incorrectly or fail when the backend has not
yet written to disk.
cy.then(() => { sessionStorage.clear(); });
cy.reload();
cy.resetStorage() (called in beforeEach) clears both localStorage and
sessionStorage; the manual clear above is needed mid-test after a write.
10. Make cleanup API-based, not UI-based
If any step in the suite fails partway through, files may end up in unexpected locations. A UI-based cleanup (visit folder → select → trash) will not find them and the cleanup test itself will fail, polluting the next run.
Use a direct API DELETE that lists every possible location with
failOnStatusCode: false:
cy.request({
failOnStatusCode: false,
method: "DELETE",
url: "/starsky/api/delete",
qs: {
f: [
`/starsky-end2end-test/${fileName}`,
`/starsky-end2end-test/child_folder/${fileName}`,
`/starsky-end2end-test/child_folder`,
].join(";"),
},
});