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 #!/usr/bin/env node
import { execFileSync } from "node:child_process"; 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 { dirname, join, resolve } from "node:path";
import { fileURLToPath } from "node:url"; import { fileURLToPath } from "node:url";
@ -16,8 +16,10 @@ const appPath = resolve(
"Release-iphonesimulator", "Release-iphonesimulator",
"mobile.app", "mobile.app",
); );
const productsPath = resolve(derivedDataPath, "Build", "Products", "Release-iphonesimulator");
const defaultOutputRoot = resolve(mobileRoot, "..", "ScreenShots"); const defaultOutputRoot = resolve(mobileRoot, "..", "ScreenShots");
const bundleId = "fr.negopoly.app"; const bundleId = "fr.negopoly.app";
const screenshotStorageKey = "negopoly:screenshot-scene";
const launchWaitMs = 4500; const launchWaitMs = 4500;
const sceneWaitMs = 2200; const sceneWaitMs = 2200;
@ -159,19 +161,78 @@ function buildReleaseApp(buildDestinationUdid) {
{ stdio: "inherit" }, { stdio: "inherit" },
); );
if (!existsSync(appPath)) { return resolveBuiltAppPath();
throw new Error(`Built app not found at ${appPath}`);
}
} }
function installApp(udid) { 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", "terminate", udid, bundleId]);
runQuiet("xcrun", ["simctl", "uninstall", udid, bundleId]); runQuiet("xcrun", ["simctl", "uninstall", udid, bundleId]);
run("xcrun", ["simctl", "install", udid, appPath]); 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 = {};
}
}
manifest[screenshotStorageKey] = scene;
writeFileSync(manifestPath, JSON.stringify(manifest), "utf8");
}
function launchApp(udid) {
run("xcrun", ["simctl", "launch", udid, bundleId]);
} }
function openScene(udid, scene, waitMs) { 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); sleep(waitMs);
} }
@ -186,14 +247,16 @@ function main() {
...target, ...target,
simulator: resolveDevice(target.matchName, devices), simulator: resolveDevice(target.matchName, devices),
})); }));
const builtAppPath = !options.skipBuild
? buildReleaseApp(resolvedTargets[0].simulator.udid)
: resolveBuiltAppPath();
if (!options.skipBuild) { if (options.skipBuild && !existsSync(builtAppPath)) {
buildReleaseApp(resolvedTargets[0].simulator.udid); throw new Error(`Cannot use --skip-build because ${builtAppPath} does not exist`);
} else if (!existsSync(appPath)) {
throw new Error(`Cannot use --skip-build because ${appPath} does not exist`);
} }
mkdirSync(options.outputRoot, { recursive: true }); mkdirSync(options.outputRoot, { recursive: true });
console.log(`Using built app: ${builtAppPath}`);
for (const target of resolvedTargets) { for (const target of resolvedTargets) {
const { simulator } = target; const { simulator } = target;
@ -203,7 +266,8 @@ function main() {
mkdirSync(deviceOutputDir, { recursive: true }); mkdirSync(deviceOutputDir, { recursive: true });
ensureBooted(simulator.udid); ensureBooted(simulator.udid);
setSimulatorAppearance(simulator.udid); setSimulatorAppearance(simulator.udid);
installApp(simulator.udid); installApp(simulator.udid, builtAppPath);
grantAppPermissions(simulator.udid);
for (const [index, scene] of scenes.entries()) { for (const [index, scene] of scenes.entries()) {
const targetPath = join(deviceOutputDir, scene.fileName); const targetPath = join(deviceOutputDir, scene.fileName);

View file

@ -64,7 +64,7 @@ function RootNavigationGate() {
const lastTargetRef = useRef<keyof RootStackParamList | null>(null); const lastTargetRef = useRef<keyof RootStackParamList | null>(null);
const lastLinkRef = useRef<string | null>(null); const lastLinkRef = useRef<string | null>(null);
const pendingNotificationRef = useRef<NotificationTarget | 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 lastNotificationIdRef = useRef<string | null>(null);
const theme = useTheme(); const theme = useTheme();
const navigationTheme = getNavigationTheme(theme); const navigationTheme = getNavigationTheme(theme);
@ -72,7 +72,6 @@ function RootNavigationGate() {
(url: string) => { (url: string) => {
const scene = extractScreenshotScene(url); const scene = extractScreenshotScene(url);
if (!scene) return false; if (!scene) return false;
pendingScreenshotRef.current = true;
manager.activateScreenshotScene(scene); manager.activateScreenshotScene(scene);
return true; return true;
}, },
@ -150,12 +149,15 @@ function RootNavigationGate() {
}, [manager.isBanker, manager.session, manager.sessionId, navReady, navigationRef]); }, [manager.isBanker, manager.session, manager.sessionId, navReady, navigationRef]);
const processPendingScreenshot = useCallback(() => { 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; if (!navReady || !navigationRef.isReady()) return;
const target = manager.screenshot?.navigationTarget; const target = screenshot.navigationTarget;
if (!target) return;
pendingScreenshotRef.current = false;
navigationRef.dispatch( navigationRef.dispatch(
CommonActions.reset({ CommonActions.reset({
index: 0, index: 0,
@ -163,10 +165,24 @@ function RootNavigationGate() {
{ {
name: target.root, name: target.root,
params: target.params, 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; lastTargetRef.current = target.root;
}, [manager.screenshot, navReady, navigationRef]); }, [manager.screenshot, navReady, navigationRef]);
@ -195,9 +211,7 @@ function RootNavigationGate() {
); );
useEffect(() => { useEffect(() => {
if (pendingScreenshotRef.current) { if (manager.screenshot) return;
return;
}
if (!navReady || !navigationRef.isReady()) return; if (!navReady || !navigationRef.isReady()) return;
const currentRoute = navigationRef.getCurrentRoute(); 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 ScreenshotScene = "start" | "lobby" | "home" | "transfers" | "chat";
export type ScreenshotNavigationState = {
index: number;
routes: Array<{
name: string;
params?: Record<string, unknown>;
state?: ScreenshotNavigationState;
}>;
};
export type ScreenshotFixture = { export type ScreenshotFixture = {
scene: ScreenshotScene; scene: ScreenshotScene;
sessionId: string; sessionId: string;
@ -11,6 +20,11 @@ export type ScreenshotFixture = {
navigationTarget: { navigationTarget: {
root: "EntryLanding" | "Lobby" | "PlayerTabs"; root: "EntryLanding" | "Lobby" | "PlayerTabs";
params?: Record<string, unknown>; params?: Record<string, unknown>;
state?: ScreenshotNavigationState;
followUp?: {
name: "PlayerTabs";
params: Record<string, unknown>;
};
}; };
transferDraft?: { transferDraft?: {
targetId: string; targetId: string;
@ -300,7 +314,14 @@ export function buildScreenshotFixture(scene: ScreenshotScene): ScreenshotFixtur
session: createActiveSession(), session: createActiveSession(),
navigationTarget: { navigationTarget: {
root: "PlayerTabs", root: "PlayerTabs",
params: { screen: "PlayerHome" }, state: {
index: 0,
routes: [{ name: "PlayerHome" }],
},
followUp: {
name: "PlayerTabs",
params: { screen: "PlayerHome" },
},
}, },
}; };
} }
@ -314,7 +335,14 @@ export function buildScreenshotFixture(scene: ScreenshotScene): ScreenshotFixtur
session: createActiveSession(), session: createActiveSession(),
navigationTarget: { navigationTarget: {
root: "PlayerTabs", root: "PlayerTabs",
params: { screen: "PlayerTransfers" }, state: {
index: 0,
routes: [{ name: "PlayerTransfers" }],
},
followUp: {
name: "PlayerTabs",
params: { screen: "PlayerTransfers" },
},
}, },
transferDraft: { transferDraft: {
targetId: malikId, targetId: malikId,
@ -332,11 +360,32 @@ export function buildScreenshotFixture(scene: ScreenshotScene): ScreenshotFixtur
session: createActiveSession(), session: createActiveSession(),
navigationTarget: { navigationTarget: {
root: "PlayerTabs", root: "PlayerTabs",
params: { state: {
screen: "PlayerChat", index: 0,
routes: [
{
name: "PlayerChat",
state: {
index: 1,
routes: [
{ name: "ChatList" },
{
name: "ChatThread",
params: { chatId: "group-direct-malik" },
},
],
},
},
],
},
followUp: {
name: "PlayerTabs",
params: { params: {
screen: "ChatThread", screen: "PlayerChat",
params: { chatId: "group-direct-malik" }, params: {
screen: "ChatThread",
params: { chatId: "group-direct-malik" },
},
}, },
}, },
}, },

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