Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
33 changes: 26 additions & 7 deletions apps/mobile/app/(tabs)/upload.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -166,6 +166,9 @@ export default function UploadScreen() {
const mountedRef = useRef(true);
const activeIdRef = useRef<string | null>(null);
const sourceBusyRef = useRef(false);
const processingIdsRef = useRef(new Set<string>());
const processingClientRef = useRef<typeof auth.client | null>(null);
processingClientRef.current = auth.status === "signedIn" ? auth.client : null;
const uploadSourceError =
sourceError?.source === "files" || sourceError?.source === "photos"
? sourceError.message
Expand Down Expand Up @@ -340,14 +343,27 @@ export default function UploadScreen() {
};

const waitForProcessing = async (queueItemId: string, capId: string) => {
if (auth.status !== "signedIn") return;
if (!mountedRef.current || processingClientRef.current !== auth.client) {
return;
}

processingIdsRef.current.add(queueItemId);
const isActive = () =>
mountedRef.current &&
processingClientRef.current === auth.client &&
processingIdsRef.current.has(queueItemId);

for (const delayMs of processingPollDelaysMs) {
await new Promise((resolve) => setTimeout(resolve, delayMs));
if (!mountedRef.current) return;
try {
for (let attempt = 0; isActive(); attempt += 1) {
const delayMs =
processingPollDelaysMs[
Math.min(attempt, processingPollDelaysMs.length - 1)
];
await new Promise((resolve) => setTimeout(resolve, delayMs));
if (!isActive()) return;

try {
const detail = await auth.client.getCap(capId);
if (!isActive()) return;
const action = uploadQueueActionFromCapUpload(
queueItemId,
detail.cap.upload,
Expand All @@ -361,9 +377,11 @@ export default function UploadScreen() {
return;
}
}
} catch {
return;
}
} catch {
return;
} finally {
processingIdsRef.current.delete(queueItemId);
}
};

Expand Down Expand Up @@ -508,6 +526,7 @@ export default function UploadScreen() {

const removeQueueItem = (item: UploadQueueItem) => {
if (activeIdRef.current !== null) return;
processingIdsRef.current.delete(item.id);
dispatch({ type: "remove", id: item.id });
};

Expand Down
237 changes: 229 additions & 8 deletions apps/mobile/src/screens/dashboard-upload-visibility.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,12 +4,13 @@ import TestRenderer, {
type ReactTestRenderer,
type ReactTestRendererJSON,
} from "react-test-renderer";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import type { MobileCapSummary } from "@/api/mobile";
import CapsScreen from "../../app/(tabs)";
import UploadScreen from "../../app/(tabs)/upload";

type AuthStub = {
status: "signedIn";
status: "signedIn" | "signedOut";
apiKey: string;
bootstrap: {
activeOrganizationId: string;
Expand Down Expand Up @@ -42,7 +43,7 @@ type AuthStub = {
}>;
getCap: (id: string) => Promise<{
cap: {
upload: null;
upload: MobileCapSummary["upload"];
};
}>;
getCapStatuses: (ids: readonly string[]) => Promise<{
Expand Down Expand Up @@ -177,9 +178,9 @@ const uploadQueueState = vi.hoisted(
}),
);

const uploadQueueActionsState = vi.hoisted((): { value: unknown[] } => ({
value: [],
}));
const uploadQueueActionsState = vi.hoisted(
(): { value: unknown[]; reduce: boolean } => ({ value: [], reduce: false }),
);

const recordingUploadState = vi.hoisted(() => ({
libraryRevision: 0,
Expand Down Expand Up @@ -592,9 +593,14 @@ vi.mock("@/uploads/uploadQueue", async (importOriginal) => {
...actual,
emptyUploadQueue: uploadQueueState.value,
uploadProgressPercent: (progress: number) => Math.round(progress * 100),
uploadQueueReducer: (state: { items: unknown[] }, action: unknown) => {
uploadQueueReducer: (
state: Parameters<typeof actual.uploadQueueReducer>[0],
action: Parameters<typeof actual.uploadQueueReducer>[1],
) => {
uploadQueueActionsState.value.push(action);
return state;
return uploadQueueActionsState.reduce
? actual.uploadQueueReducer(state, action)
: state;
},
uploadQueueStatusText: (item: {
processingMessage?: string | null;
Expand Down Expand Up @@ -1754,6 +1760,221 @@ describe("upload and dashboard visibility", () => {
}
});

describe("processing status polling", () => {
const processingDetail = {
cap: {
upload: {
phase: "processing" as const,
processingError: null,
processingMessage: "Converting video",
processingProgress: 42,
total: 100,
uploaded: 100,
},
},
};
const cancellations = [
"removal",
"unmount",
"sign-out",
"new session",
] as const;
let auth: AuthStub;
let renderer: ReactTestRenderer | undefined;

beforeEach(async () => {
vi.useFakeTimers();
uploadQueueActionsState.reduce = true;
auth = createAuth();
auth.refresh = vi.fn(() => Promise.resolve());
auth.client.getCap = vi.fn(() => Promise.resolve(processingDetail));
authState.value = auth;
const DocumentPicker = await import("expo-document-picker");
vi.mocked(DocumentPicker.getDocumentAsync).mockResolvedValueOnce({
assets: [
{
name: "slow.mp4",
uri: "file:///tmp/slow.mp4",
size: 100,
lastModified: 0,
},
],
canceled: false,
});
const { runMobileUpload } = await import("@/uploads/runMobileUpload");
vi.mocked(runMobileUpload).mockImplementationOnce(
async ({ onCreated }) => {
onCreated?.("video_123", "raw.mp4");
return { id: "video_123" } as Awaited<
ReturnType<typeof runMobileUpload>
>;
},
);
});

afterEach(async () => {
await act(async () => renderer?.unmount());
renderer = undefined;
uploadQueueActionsState.reduce = false;
vi.clearAllTimers();
vi.useRealTimers();
});

const startImport = async () => {
renderer = await renderComponent(React.createElement(UploadScreen));
const [browseButton] = renderer.root.findAllByProps({
accessibilityLabel: "Browse Files",
});
if (!browseButton)
throw new Error("Browse Files button was not rendered");
await act(async () => {
void browseButton.props.onPress();
});
return renderer;
};

const cancel = async (reason: (typeof cancellations)[number]) => {
if (!renderer) throw new Error("Upload screen was not rendered");
if (reason === "removal") {
const { ActionSheetIOS } = await import("react-native");
const sheet = vi.mocked(ActionSheetIOS.showActionSheetWithOptions);
sheet.mockClear();
const [menu] = renderer.root.findAllByProps({
accessibilityLabel: "More actions for slow.mp4",
});
if (!menu) throw new Error("Upload queue menu was not rendered");
await act(async () => {
menu.props.onPress({ stopPropagation: vi.fn() });
});
const call = sheet.mock.calls.at(-1);
if (!call) throw new Error("Upload queue sheet did not open");
await act(async () => {
call[1](call[0].options.indexOf("Remove from Queue"));
});
expect(
renderer.root.findAllByProps({
accessibilityLabel: "Upload slow.mp4",
}),
).toHaveLength(0);
return;
}
if (reason === "unmount") {
await act(async () => renderer?.unmount());
renderer = undefined;
return;
}
authState.value =
reason === "sign-out"
? { ...auth, status: "signedOut" }
: { ...createAuth(), apiKey: "new-api-key" };
await act(async () =>
renderer?.update(React.createElement(UploadScreen)),
);
};

it.each(["complete", "error"] as const)(
"observes a late %s after the initial four checks",
async (phase) => {
const startedAt = Date.now();
auth.client.getCap = vi.fn(async () => {
if (Date.now() - startedAt < 30_000) return processingDetail;
return {
cap: {
upload: {
...processingDetail.cap.upload,
phase,
processingError: phase === "error" ? "Conversion failed" : null,
},
},
};
});
const screen = await startImport();
await act(async () => {
await vi.advanceTimersByTimeAsync(17_500);
});
expect(auth.client.getCap).toHaveBeenCalledTimes(4);
expect(getTextNodes(screen.toJSON()).join(" ")).toContain(
"Converting video",
);
await act(async () => {
await vi.advanceTimersByTimeAsync(16_000);
});
expect(auth.client.getCap).toHaveBeenCalledTimes(6);
expect(getTextNodes(screen.toJSON()).join(" ")).toContain(
phase === "complete" ? "Ready to view" : "Conversion failed",
);
expect(auth.refresh).toHaveBeenCalledTimes(
phase === "complete" ? 2 : 1,
);
if (phase === "error") {
expect(
screen.root.findAllByProps({
accessibilityLabel: "Retry upload slow.mp4",
}).length,
).toBeGreaterThan(0);
}
await act(async () => {
await vi.advanceTimersByTimeAsync(32_000);
});
expect(auth.client.getCap).toHaveBeenCalledTimes(6);
},
);

it.each(cancellations)(
"stops requests after %s while waiting",
async (reason) => {
await startImport();
await cancel(reason);
await act(async () => {
await vi.advanceTimersByTimeAsync(40_000);
});
expect(auth.client.getCap).not.toHaveBeenCalled();
expect(auth.refresh).toHaveBeenCalledTimes(1);
},
);

it.each(cancellations)(
"ignores an in-flight reply after %s",
async (reason) => {
const detail =
createDeferred<Awaited<ReturnType<AuthStub["client"]["getCap"]>>>();
auth.client.getCap = vi.fn(() => detail.promise);
await startImport();
await act(async () => {
await vi.advanceTimersByTimeAsync(1500);
});
expect(auth.client.getCap).toHaveBeenCalledTimes(1);
await cancel(reason);
await act(async () => {
detail.resolve({ cap: { upload: null } });
});
expect(uploadQueueActionsState.value).not.toContainEqual(
expect.objectContaining({ type: "complete" }),
);
expect(auth.refresh).toHaveBeenCalledTimes(1);
await act(async () => {
await vi.advanceTimersByTimeAsync(40_000);
});
expect(auth.client.getCap).toHaveBeenCalledTimes(1);
},
);

it("does not start an old upload's poll after a session change during refresh", async () => {
const refresh = createDeferred<void>();
auth.refresh = vi.fn(() => refresh.promise);
await startImport();
expect(auth.refresh).toHaveBeenCalledTimes(1);
await cancel("new session");
await act(async () => {
refresh.resolve();
});
await act(async () => {
await vi.advanceTimersByTimeAsync(40_000);
});
expect(auth.client.getCap).not.toHaveBeenCalled();
});
});

it("opens the native iOS upload queue sheet", async () => {
uploadQueueState.value.items = [
{
Expand Down
Loading