Automatisation des screenshots

This commit is contained in:
Feror 2026-03-30 11:15:50 +02:00
parent d8121e74a4
commit 01a77b5fc9
14 changed files with 230 additions and 38 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 171 KiB

After

Width:  |  Height:  |  Size: 223 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 188 KiB

After

Width:  |  Height:  |  Size: 221 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 163 KiB

After

Width:  |  Height:  |  Size: 246 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 271 KiB

After

Width:  |  Height:  |  Size: 197 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 239 KiB

After

Width:  |  Height:  |  Size: 252 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 163 KiB

After

Width:  |  Height:  |  Size: 242 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 187 KiB

After

Width:  |  Height:  |  Size: 232 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 152 KiB

After

Width:  |  Height:  |  Size: 279 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 328 KiB

After

Width:  |  Height:  |  Size: 204 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 256 KiB

After

Width:  |  Height:  |  Size: 274 KiB

View file

@ -1,7 +1,7 @@
#!/usr/bin/env node
import { execFileSync } from "node:child_process";
import { existsSync, mkdirSync } from "node:fs";
import { existsSync, mkdirSync, readFileSync, readdirSync, writeFileSync } from "node:fs";
import { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url";
@ -16,8 +16,10 @@ const appPath = resolve(
"Release-iphonesimulator",
"mobile.app",
);
const productsPath = resolve(derivedDataPath, "Build", "Products", "Release-iphonesimulator");
const defaultOutputRoot = resolve(mobileRoot, "..", "ScreenShots");
const bundleId = "fr.negopoly.app";
const screenshotStorageKey = "negopoly:screenshot-scene";
const launchWaitMs = 4500;
const sceneWaitMs = 2200;
@ -159,19 +161,78 @@ function buildReleaseApp(buildDestinationUdid) {
{ stdio: "inherit" },
);
if (!existsSync(appPath)) {
throw new Error(`Built app not found at ${appPath}`);
return resolveBuiltAppPath();
}
function resolveBuiltAppPath() {
if (existsSync(appPath)) {
return appPath;
}
if (!existsSync(productsPath)) {
throw new Error(`Build products directory not found at ${productsPath}`);
}
const candidates = readdirSync(productsPath)
.filter((entry) => entry.endsWith(".app"))
.filter((entry) => !entry.endsWith(".app.dSYM"))
.sort((left, right) => left.localeCompare(right));
if (candidates.length === 0) {
throw new Error(`Built app not found under ${productsPath}`);
}
return join(productsPath, candidates[0]);
}
function installApp(udid, builtAppPath) {
runQuiet("xcrun", ["simctl", "terminate", udid, bundleId]);
runQuiet("xcrun", ["simctl", "uninstall", udid, bundleId]);
run("xcrun", ["simctl", "install", udid, builtAppPath]);
}
function grantAppPermissions(udid) {
runQuiet("xcrun", ["simctl", "privacy", udid, "grant", "notifications", bundleId]);
}
function getDataContainerPath(udid) {
return run("xcrun", ["simctl", "get_app_container", udid, bundleId, "data"]).trim();
}
function writeScreenshotScene(udid, scene) {
const containerPath = getDataContainerPath(udid);
const storageDir = join(
containerPath,
"Library",
"Application Support",
bundleId,
"RCTAsyncLocalStorage_V1",
);
const manifestPath = join(storageDir, "manifest.json");
mkdirSync(storageDir, { recursive: true });
let manifest = {};
if (existsSync(manifestPath)) {
try {
manifest = JSON.parse(readFileSync(manifestPath, "utf8"));
} catch {
manifest = {};
}
}
function installApp(udid) {
runQuiet("xcrun", ["simctl", "terminate", udid, bundleId]);
runQuiet("xcrun", ["simctl", "uninstall", udid, bundleId]);
run("xcrun", ["simctl", "install", udid, appPath]);
manifest[screenshotStorageKey] = scene;
writeFileSync(manifestPath, JSON.stringify(manifest), "utf8");
}
function launchApp(udid) {
run("xcrun", ["simctl", "launch", udid, bundleId]);
}
function openScene(udid, scene, waitMs) {
run("xcrun", ["simctl", "openurl", udid, `negopoly://screenshot/${scene}`]);
runQuiet("xcrun", ["simctl", "terminate", udid, bundleId]);
writeScreenshotScene(udid, scene);
launchApp(udid);
sleep(waitMs);
}
@ -186,14 +247,16 @@ function main() {
...target,
simulator: resolveDevice(target.matchName, devices),
}));
const builtAppPath = !options.skipBuild
? buildReleaseApp(resolvedTargets[0].simulator.udid)
: resolveBuiltAppPath();
if (!options.skipBuild) {
buildReleaseApp(resolvedTargets[0].simulator.udid);
} else if (!existsSync(appPath)) {
throw new Error(`Cannot use --skip-build because ${appPath} does not exist`);
if (options.skipBuild && !existsSync(builtAppPath)) {
throw new Error(`Cannot use --skip-build because ${builtAppPath} does not exist`);
}
mkdirSync(options.outputRoot, { recursive: true });
console.log(`Using built app: ${builtAppPath}`);
for (const target of resolvedTargets) {
const { simulator } = target;
@ -203,7 +266,8 @@ function main() {
mkdirSync(deviceOutputDir, { recursive: true });
ensureBooted(simulator.udid);
setSimulatorAppearance(simulator.udid);
installApp(simulator.udid);
installApp(simulator.udid, builtAppPath);
grantAppPermissions(simulator.udid);
for (const [index, scene] of scenes.entries()) {
const targetPath = join(deviceOutputDir, scene.fileName);

View file

@ -64,7 +64,7 @@ function RootNavigationGate() {
const lastTargetRef = useRef<keyof RootStackParamList | null>(null);
const lastLinkRef = useRef<string | null>(null);
const pendingNotificationRef = useRef<NotificationTarget | null>(null);
const pendingScreenshotRef = useRef(false);
const lastScreenshotSceneRef = useRef<string | null>(null);
const lastNotificationIdRef = useRef<string | null>(null);
const theme = useTheme();
const navigationTheme = getNavigationTheme(theme);
@ -72,7 +72,6 @@ function RootNavigationGate() {
(url: string) => {
const scene = extractScreenshotScene(url);
if (!scene) return false;
pendingScreenshotRef.current = true;
manager.activateScreenshotScene(scene);
return true;
},
@ -150,12 +149,15 @@ function RootNavigationGate() {
}, [manager.isBanker, manager.session, manager.sessionId, navReady, navigationRef]);
const processPendingScreenshot = useCallback(() => {
if (!pendingScreenshotRef.current) return;
const screenshot = manager.screenshot;
if (!screenshot) {
lastScreenshotSceneRef.current = null;
return;
}
if (lastScreenshotSceneRef.current === screenshot.scene) return;
if (!navReady || !navigationRef.isReady()) return;
const target = manager.screenshot?.navigationTarget;
if (!target) return;
const target = screenshot.navigationTarget;
pendingScreenshotRef.current = false;
navigationRef.dispatch(
CommonActions.reset({
index: 0,
@ -163,10 +165,24 @@ function RootNavigationGate() {
{
name: target.root,
params: target.params,
state: target.state,
},
],
}),
);
const followUp = target.followUp;
if (followUp) {
setTimeout(() => {
if (!navigationRef.isReady()) return;
navigationRef.dispatch(
CommonActions.navigate({
name: followUp.name,
params: followUp.params,
}),
);
}, 0);
}
lastScreenshotSceneRef.current = screenshot.scene;
lastTargetRef.current = target.root;
}, [manager.screenshot, navReady, navigationRef]);
@ -195,9 +211,7 @@ function RootNavigationGate() {
);
useEffect(() => {
if (pendingScreenshotRef.current) {
return;
}
if (manager.screenshot) return;
if (!navReady || !navigationRef.isReady()) return;
const currentRoute = navigationRef.getCurrentRoute();

View file

@ -2,6 +2,15 @@ import type { SessionSnapshot } from "../shared/types";
export type ScreenshotScene = "start" | "lobby" | "home" | "transfers" | "chat";
export type ScreenshotNavigationState = {
index: number;
routes: Array<{
name: string;
params?: Record<string, unknown>;
state?: ScreenshotNavigationState;
}>;
};
export type ScreenshotFixture = {
scene: ScreenshotScene;
sessionId: string;
@ -11,6 +20,11 @@ export type ScreenshotFixture = {
navigationTarget: {
root: "EntryLanding" | "Lobby" | "PlayerTabs";
params?: Record<string, unknown>;
state?: ScreenshotNavigationState;
followUp?: {
name: "PlayerTabs";
params: Record<string, unknown>;
};
};
transferDraft?: {
targetId: string;
@ -300,8 +314,15 @@ export function buildScreenshotFixture(scene: ScreenshotScene): ScreenshotFixtur
session: createActiveSession(),
navigationTarget: {
root: "PlayerTabs",
state: {
index: 0,
routes: [{ name: "PlayerHome" }],
},
followUp: {
name: "PlayerTabs",
params: { screen: "PlayerHome" },
},
},
};
}
@ -314,8 +335,15 @@ export function buildScreenshotFixture(scene: ScreenshotScene): ScreenshotFixtur
session: createActiveSession(),
navigationTarget: {
root: "PlayerTabs",
state: {
index: 0,
routes: [{ name: "PlayerTransfers" }],
},
followUp: {
name: "PlayerTabs",
params: { screen: "PlayerTransfers" },
},
},
transferDraft: {
targetId: malikId,
amount: "90",
@ -332,6 +360,26 @@ export function buildScreenshotFixture(scene: ScreenshotScene): ScreenshotFixtur
session: createActiveSession(),
navigationTarget: {
root: "PlayerTabs",
state: {
index: 0,
routes: [
{
name: "PlayerChat",
state: {
index: 1,
routes: [
{ name: "ChatList" },
{
name: "ChatThread",
params: { chatId: "group-direct-malik" },
},
],
},
},
],
},
followUp: {
name: "PlayerTabs",
params: {
screen: "PlayerChat",
params: {
@ -340,5 +388,6 @@ export function buildScreenshotFixture(scene: ScreenshotScene): ScreenshotFixtur
},
},
},
},
};
}

View file

@ -1,4 +1,4 @@
import { useCallback, useEffect, useRef, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { AppState, type AppStateStatus } from "react-native";
import AsyncStorage from "@react-native-async-storage/async-storage";
import type { JoinResponse, SessionPreview, SessionSnapshot } from "../shared/types";
@ -20,6 +20,7 @@ import {
} from "./connection";
const STORAGE_KEY = "negopoly:session";
const SCREENSHOT_STORAGE_KEY = "negopoly:screenshot-scene";
type StoredSession = {
sessionId: string;
@ -51,12 +52,34 @@ async function clearStoredSession() {
await AsyncStorage.removeItem(STORAGE_KEY);
}
async function readStoredScreenshotScene(): Promise<ScreenshotScene | null> {
try {
const raw = await AsyncStorage.getItem(SCREENSHOT_STORAGE_KEY);
return raw === "start" ||
raw === "lobby" ||
raw === "home" ||
raw === "transfers" ||
raw === "chat"
? raw
: null;
} catch {
return null;
}
}
async function clearStoredScreenshotScene() {
await AsyncStorage.removeItem(SCREENSHOT_STORAGE_KEY);
}
export function useSessionManager() {
const [sessionId, setSessionId] = useState("");
const [sessionCode, setSessionCode] = useState("");
const [playerId, setPlayerId] = useState("");
const [session, setSession] = useState<SessionSnapshot | null>(null);
const [screenshot, setScreenshot] = useState<ScreenshotFixture | null>(null);
const [bootstrapReady, setBootstrapReady] = useState(false);
const [pushRegistrationReady, setPushRegistrationReady] = useState(false);
const [screenshotBootstrapPending, setScreenshotBootstrapPending] = useState(false);
const [error, setError] = useState<string | null>(null);
const [connectionState, setConnectionState] =
useState<SessionConnectionState>("idle");
@ -318,25 +341,59 @@ export function useSessionManager() {
screenshotRef.current = screenshot;
}, [screenshot]);
useEffect(() => {
if (screenshot) {
setScreenshotBootstrapPending(false);
}
}, [screenshot]);
useEffect(() => {
sessionRef.current = session;
}, [session]);
useEffect(() => {
let mounted = true;
readStoredSession().then((stored) => {
(async () => {
let enablePushRegistration = true;
try {
const screenshotScene = await readStoredScreenshotScene();
if (!mounted) return;
if (screenshotScene) {
enablePushRegistration = false;
setScreenshotBootstrapPending(true);
await clearStoredScreenshotScene();
if (!mounted) return;
activateScreenshotScene(screenshotScene);
return;
}
const stored = await readStoredSession();
if (!mounted || !stored || screenshotRef.current) return;
setSessionId(stored.sessionId);
setSessionCode(stored.sessionCode);
setPlayerId(stored.playerId);
});
} finally {
if (mounted) {
setPushRegistrationReady(enablePushRegistration);
setBootstrapReady(true);
}
}
})();
return () => {
mounted = false;
};
}, []);
useEffect(() => {
if (screenshot) return;
if (
!bootstrapReady ||
!pushRegistrationReady ||
screenshot ||
screenshotBootstrapPending ||
!sessionId ||
!playerId
) {
return;
}
let mounted = true;
registerForPushNotificationsAsync().then((token) => {
if (!mounted) return;
@ -345,7 +402,14 @@ export function useSessionManager() {
return () => {
mounted = false;
};
}, [screenshot]);
}, [
bootstrapReady,
playerId,
pushRegistrationReady,
screenshot,
screenshotBootstrapPending,
sessionId,
]);
useEffect(() => {
if (screenshot) return;
@ -423,9 +487,10 @@ export function useSessionManager() {
};
}, [playerId, screenshot, sessionId]);
const activateScreenshotScene = useCallback((scene: ScreenshotScene) => {
function activateScreenshotScene(scene: ScreenshotScene) {
const fixture = buildScreenshotFixture(scene);
suppressReconnectRef.current = true;
setPushRegistrationReady(false);
teardownConnection();
reconnectAttemptRef.current = 0;
setReconnectAttempt(0);
@ -438,7 +503,7 @@ export function useSessionManager() {
setSessionCode(fixture.sessionCode);
setPlayerId(fixture.playerId);
setSession(fixture.session);
}, []);
}
async function registerPushTokenFor(targetSessionId: string, targetPlayerId: string) {
if (!pushToken) return;