Compare commits
2 Commits
da373199e0
...
a213b1593a
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
a213b1593a | ||
|
|
41c7634b75 |
@@ -84,6 +84,7 @@ android {
|
|||||||
targetSdkVersion rootProject.ext.targetSdkVersion
|
targetSdkVersion rootProject.ext.targetSdkVersion
|
||||||
versionCode 1
|
versionCode 1
|
||||||
versionName "1.0"
|
versionName "1.0"
|
||||||
|
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
|
||||||
}
|
}
|
||||||
signingConfigs {
|
signingConfigs {
|
||||||
debug {
|
debug {
|
||||||
@@ -110,6 +111,7 @@ android {
|
|||||||
dependencies {
|
dependencies {
|
||||||
// The version of react-native is set by the React Native Gradle Plugin
|
// The version of react-native is set by the React Native Gradle Plugin
|
||||||
implementation("com.facebook.react:react-android")
|
implementation("com.facebook.react:react-android")
|
||||||
|
androidTestImplementation('com.wix:detox:+')
|
||||||
|
|
||||||
if (hermesEnabled.toBoolean()) {
|
if (hermesEnabled.toBoolean()) {
|
||||||
implementation("com.facebook.react:hermes-android")
|
implementation("com.facebook.react:hermes-android")
|
||||||
|
|||||||
@@ -0,0 +1,31 @@
|
|||||||
|
package com.mymobileagent;
|
||||||
|
|
||||||
|
import com.wix.detox.Detox;
|
||||||
|
import com.wix.detox.config.DetoxConfig;
|
||||||
|
|
||||||
|
import org.junit.Rule;
|
||||||
|
import org.junit.Test;
|
||||||
|
import org.junit.runner.RunWith;
|
||||||
|
|
||||||
|
import androidx.test.ext.junit.runners.AndroidJUnit4;
|
||||||
|
import androidx.test.filters.LargeTest;
|
||||||
|
import androidx.test.rule.ActivityTestRule;
|
||||||
|
|
||||||
|
@RunWith(AndroidJUnit4.class)
|
||||||
|
@LargeTest
|
||||||
|
public class DetoxTest {
|
||||||
|
|
||||||
|
@Rule
|
||||||
|
public ActivityTestRule<MainActivity> mActivityRule =
|
||||||
|
new ActivityTestRule<>(MainActivity.class, false, false);
|
||||||
|
|
||||||
|
@Test
|
||||||
|
public void runDetoxTests() {
|
||||||
|
DetoxConfig detoxConfig = new DetoxConfig();
|
||||||
|
detoxConfig.idlePolicyConfig.masterTimeoutSec = 90;
|
||||||
|
detoxConfig.idlePolicyConfig.idleResourceTimeoutSec = 60;
|
||||||
|
detoxConfig.rnContextLoadTimeoutSec = (BuildConfig.DEBUG ? 180 : 60);
|
||||||
|
|
||||||
|
Detox.runTests(mActivityRule, detoxConfig);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -16,7 +16,7 @@
|
|||||||
android:roundIcon="@mipmap/ic_launcher_round"
|
android:roundIcon="@mipmap/ic_launcher_round"
|
||||||
android:allowBackup="false"
|
android:allowBackup="false"
|
||||||
android:theme="@style/AppTheme"
|
android:theme="@style/AppTheme"
|
||||||
android:usesCleartextTraffic="${usesCleartextTraffic}"
|
android:networkSecurityConfig="@xml/network_security_config"
|
||||||
android:supportsRtl="true">
|
android:supportsRtl="true">
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<network-security-config>
|
||||||
|
<domain-config cleartextTrafficPermitted="true">
|
||||||
|
<domain includeSubdomains="true">10.0.2.2</domain>
|
||||||
|
<domain includeSubdomains="true">localhost</domain>
|
||||||
|
</domain-config>
|
||||||
|
</network-security-config>
|
||||||
@@ -19,3 +19,12 @@ buildscript {
|
|||||||
}
|
}
|
||||||
|
|
||||||
apply plugin: "com.facebook.react.rootproject"
|
apply plugin: "com.facebook.react.rootproject"
|
||||||
|
|
||||||
|
allprojects {
|
||||||
|
repositories {
|
||||||
|
maven {
|
||||||
|
url("$rootDir/../node_modules/detox/Detox-android")
|
||||||
|
}
|
||||||
|
maven { url 'https://www.jitpack.io' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
48
app/detox.config.js
Normal file
48
app/detox.config.js
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
/**
|
||||||
|
* Detox configuration with both attached-device and emulator options.
|
||||||
|
* A helper script (e2e/run-detox.js) will pick the attached device if present,
|
||||||
|
* otherwise fall back to the emulator configuration.
|
||||||
|
*/
|
||||||
|
module.exports = {
|
||||||
|
testRunner: {
|
||||||
|
$0: 'jest',
|
||||||
|
args: {
|
||||||
|
config: 'e2e/jest.config.js',
|
||||||
|
_: ['e2e']
|
||||||
|
}
|
||||||
|
},
|
||||||
|
devices: {
|
||||||
|
'android.attached': {
|
||||||
|
type: 'android.attached',
|
||||||
|
device: {
|
||||||
|
adbName: '.*' // match any attached device
|
||||||
|
}
|
||||||
|
},
|
||||||
|
'android.emulator': {
|
||||||
|
type: 'android.emulator',
|
||||||
|
device: {
|
||||||
|
avdName: 'Pixel_3a_API_30_x86'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
apps: {
|
||||||
|
'android.debug': {
|
||||||
|
type: 'android.apk',
|
||||||
|
binaryPath: 'android/app/build/outputs/apk/debug/app-debug.apk',
|
||||||
|
build: 'cd android && ./gradlew assembleDebug assembleAndroidTest -DtestBuildType=debug && cd ..',
|
||||||
|
testBinaryPath: 'android/app/build/outputs/apk/androidTest/debug/app-debug-androidTest.apk',
|
||||||
|
reversePorts: [8081],
|
||||||
|
launchTimeout: 120000 // wait up to 2 min for the app to connect (Metro must be running)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
configurations: {
|
||||||
|
'android.attached+android.debug': {
|
||||||
|
device: 'android.attached',
|
||||||
|
app: 'android.debug'
|
||||||
|
},
|
||||||
|
'android.emu.debug+android.debug': {
|
||||||
|
device: 'android.emulator',
|
||||||
|
app: 'android.debug'
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
109
app/e2e/agents.e2e.js
Normal file
109
app/e2e/agents.e2e.js
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
/**
|
||||||
|
* e2e/agents.e2e.js
|
||||||
|
*
|
||||||
|
* Verifies the Agents screen:
|
||||||
|
* - screen renders correctly
|
||||||
|
* - new agent can be created
|
||||||
|
* - created agent appears in the list
|
||||||
|
* - agent editor can be reopened (edit flow)
|
||||||
|
* - agent can be deleted via the delete button
|
||||||
|
*/
|
||||||
|
const { navigatePastSplash, tapTab } = require('./helpers');
|
||||||
|
|
||||||
|
const TEST_AGENT_NAME = 'E2E Test Agent';
|
||||||
|
const TEST_AGENT_ID = 'E2E-Test-Agent'; // name with spaces replaced by dashes
|
||||||
|
|
||||||
|
describe('Agents Screen', () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
await device.launchApp({ newInstance: true });
|
||||||
|
await navigatePastSplash();
|
||||||
|
await tapTab('Agents');
|
||||||
|
// Use testID to avoid matching the bottom tab bar "Agents" label
|
||||||
|
await waitFor(element(by.id('agents-screen-title')))
|
||||||
|
.toBeVisible()
|
||||||
|
.withTimeout(5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Render ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('shows the Agents screen header', async () => {
|
||||||
|
await expect(element(by.id('agents-screen-title'))).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the Nouvel agent button', async () => {
|
||||||
|
await expect(element(by.id('new-agent-btn'))).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Create agent ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('opens the agent editor when tapping Nouvel agent', async () => {
|
||||||
|
await element(by.id('new-agent-btn')).tap();
|
||||||
|
await waitFor(element(by.text("Nom de l'agent")))
|
||||||
|
.toBeVisible()
|
||||||
|
.withTimeout(4000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the name input in the editor', async () => {
|
||||||
|
await expect(element(by.id('agent-name-input'))).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the Créer save button for a new agent', async () => {
|
||||||
|
await expect(element(by.id('agent-save-btn'))).toBeVisible();
|
||||||
|
await expect(element(by.text('Créer'))).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('fills in the agent name and saves', async () => {
|
||||||
|
await element(by.id('agent-name-input')).clearText();
|
||||||
|
await element(by.id('agent-name-input')).typeText(TEST_AGENT_NAME);
|
||||||
|
await element(by.id('agent-save-btn')).tap();
|
||||||
|
// Editor should close after save
|
||||||
|
await waitFor(element(by.text("Nom de l'agent")))
|
||||||
|
.not.toBeVisible()
|
||||||
|
.withTimeout(5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the newly created agent in the list', async () => {
|
||||||
|
await waitFor(element(by.text(TEST_AGENT_NAME)))
|
||||||
|
.toBeVisible()
|
||||||
|
.withTimeout(5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Edit agent ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('opens the editor when tapping on an existing agent card', async () => {
|
||||||
|
await element(by.text(TEST_AGENT_NAME)).tap();
|
||||||
|
await waitFor(element(by.text("Nom de l'agent")))
|
||||||
|
.toBeVisible()
|
||||||
|
.withTimeout(4000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows "Mettre à jour" button when editing an existing agent', async () => {
|
||||||
|
await expect(element(by.text('Mettre à jour'))).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('closes the editor without saving when tapping Annuler', async () => {
|
||||||
|
await element(by.text('Annuler')).tap();
|
||||||
|
await waitFor(element(by.text("Nom de l'agent")))
|
||||||
|
.not.toBeVisible()
|
||||||
|
.withTimeout(4000);
|
||||||
|
// Agent name still visible in the list
|
||||||
|
await expect(element(by.text(TEST_AGENT_NAME))).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Delete agent ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('shows the delete confirmation alert when tapping the delete button', async () => {
|
||||||
|
await element(by.id(`delete-agent-${TEST_AGENT_ID}`)).tap();
|
||||||
|
await waitFor(element(by.text('Supprimer')))
|
||||||
|
.toBeVisible()
|
||||||
|
.withTimeout(4000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('removes the agent after confirming deletion', async () => {
|
||||||
|
// Tap the destructive "Supprimer" action in the Alert
|
||||||
|
await element(by.text('Supprimer')).tap();
|
||||||
|
await waitFor(element(by.text(TEST_AGENT_NAME)))
|
||||||
|
.not.toBeVisible()
|
||||||
|
.withTimeout(5000);
|
||||||
|
});
|
||||||
|
});
|
||||||
61
app/e2e/chat.e2e.js
Normal file
61
app/e2e/chat.e2e.js
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* e2e/chat.e2e.js
|
||||||
|
*
|
||||||
|
* Verifies the Chat screen UI:
|
||||||
|
* - message input renders
|
||||||
|
* - send button renders
|
||||||
|
* - input accepts text when no model is loaded (editable state check)
|
||||||
|
* - send is disabled when input is empty
|
||||||
|
* - UI shows an indication that no model is loaded
|
||||||
|
*/
|
||||||
|
const { navigatePastSplash, tapTab } = require('./helpers');
|
||||||
|
|
||||||
|
describe('Chat Screen', () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
await device.launchApp({ newInstance: true });
|
||||||
|
await navigatePastSplash();
|
||||||
|
await tapTab('Chat');
|
||||||
|
await waitFor(element(by.id('chat-input-field')))
|
||||||
|
.toBeVisible()
|
||||||
|
.withTimeout(5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Render ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('shows the message input field', async () => {
|
||||||
|
await expect(element(by.id('chat-input-field'))).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the send button', async () => {
|
||||||
|
await expect(element(by.id('chat-send-btn'))).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Input bar ───────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('send button is disabled when the input is empty', async () => {
|
||||||
|
// The button is rendered but disabled when no text is entered.
|
||||||
|
// Tapping a disabled button should not trigger a send (no crash).
|
||||||
|
await element(by.id('chat-send-btn')).tap();
|
||||||
|
// If we reach here without crash, the disabled guard works.
|
||||||
|
await expect(element(by.id('chat-send-btn'))).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── New conversation ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('renders a new conversation title area', async () => {
|
||||||
|
// The active conversation default title is shown in the header area.
|
||||||
|
// We just verify the screen is still stable.
|
||||||
|
await expect(element(by.id('chat-input-field'))).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Navigation stability ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('switching to another tab and back preserves the chat screen', async () => {
|
||||||
|
await tapTab('Agents');
|
||||||
|
await waitFor(element(by.id('agents-screen-title'))).toBeVisible().withTimeout(4000);
|
||||||
|
await tapTab('Chat');
|
||||||
|
await waitFor(element(by.id('chat-input-field')))
|
||||||
|
.toBeVisible()
|
||||||
|
.withTimeout(4000);
|
||||||
|
});
|
||||||
|
});
|
||||||
27
app/e2e/first.e2e.js
Normal file
27
app/e2e/first.e2e.js
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
const { navigatePastSplash } = require('./helpers');
|
||||||
|
|
||||||
|
describe('App basic e2e', () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
await device.launchApp({ newInstance: true });
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the landing splash screen', async () => {
|
||||||
|
// The landing screen shows a logo for 5 seconds before navigating
|
||||||
|
await waitFor(element(by.id('landing-logo')))
|
||||||
|
.toBeVisible()
|
||||||
|
.withTimeout(10000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('navigates to main tabs after the splash', async () => {
|
||||||
|
// Landing screen auto-navigates after 5s
|
||||||
|
await waitFor(element(by.text('Modèles')))
|
||||||
|
.toBeVisible()
|
||||||
|
.withTimeout(10000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the Models sub-tabs after landing', async () => {
|
||||||
|
await waitFor(element(by.text('Modèles locaux')).atIndex(0))
|
||||||
|
.toBeVisible()
|
||||||
|
.withTimeout(5000);
|
||||||
|
});
|
||||||
|
});
|
||||||
29
app/e2e/helpers.js
Normal file
29
app/e2e/helpers.js
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
/**
|
||||||
|
* Shared Detox helpers for e2e tests.
|
||||||
|
*/
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait for the app to pass the 5-second landing splash and reach MainTabs.
|
||||||
|
* Call this in beforeAll() after device.launchApp().
|
||||||
|
*/
|
||||||
|
async function navigatePastSplash() {
|
||||||
|
// 1. Splash logo must appear first
|
||||||
|
await waitFor(element(by.id('landing-logo')))
|
||||||
|
.toBeVisible()
|
||||||
|
.withTimeout(10000);
|
||||||
|
|
||||||
|
// 2. After the 5-second timer, the app navigates to MainTabs.
|
||||||
|
// The bottom tab label "Modèles" is the first visible tab bar item.
|
||||||
|
await waitFor(element(by.text('Modèles')))
|
||||||
|
.toBeVisible()
|
||||||
|
.withTimeout(12000);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tap a bottom tab by its visible label.
|
||||||
|
*/
|
||||||
|
async function tapTab(label) {
|
||||||
|
await element(by.text(label)).tap();
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = { navigatePastSplash, tapTab };
|
||||||
11
app/e2e/jest.config.js
Normal file
11
app/e2e/jest.config.js
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
module.exports = {
|
||||||
|
maxWorkers: 1,
|
||||||
|
rootDir: '..',
|
||||||
|
testMatch: ['<rootDir>/e2e/**/*.e2e.[jt]s?(x)'],
|
||||||
|
testTimeout: 120000,
|
||||||
|
verbose: true,
|
||||||
|
reporters: ['detox/runners/jest/reporter'],
|
||||||
|
globalSetup: 'detox/runners/jest/globalSetup',
|
||||||
|
globalTeardown: 'detox/runners/jest/globalTeardown',
|
||||||
|
testEnvironment: 'detox/runners/jest/testEnvironment'
|
||||||
|
};
|
||||||
86
app/e2e/navigation.e2e.js
Normal file
86
app/e2e/navigation.e2e.js
Normal file
@@ -0,0 +1,86 @@
|
|||||||
|
/**
|
||||||
|
* e2e/navigation.e2e.js
|
||||||
|
*
|
||||||
|
* Verifies that every bottom-tab destination renders correctly.
|
||||||
|
*/
|
||||||
|
const { navigatePastSplash, tapTab } = require('./helpers');
|
||||||
|
|
||||||
|
describe('Tab Navigation', () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
await device.launchApp({ newInstance: true });
|
||||||
|
await navigatePastSplash();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Models (default tab) ────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('opens the Models tab by default', async () => {
|
||||||
|
// MaterialTopTabNavigator renders tab labels twice (tab bar + pager) — use atIndex(0)
|
||||||
|
await waitFor(element(by.text('Mod\u00e8les locaux')).atIndex(0))
|
||||||
|
.toBeVisible()
|
||||||
|
.withTimeout(5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the HuggingFace sub-tab inside Models', async () => {
|
||||||
|
await waitFor(element(by.text('HuggingFace')).atIndex(0))
|
||||||
|
.toBeVisible()
|
||||||
|
.withTimeout(5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Chat ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('navigates to Chat tab and shows the message input', async () => {
|
||||||
|
await tapTab('Chat');
|
||||||
|
await waitFor(element(by.id('chat-input-field')))
|
||||||
|
.toBeVisible()
|
||||||
|
.withTimeout(5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the send button in Chat tab', async () => {
|
||||||
|
await waitFor(element(by.id('chat-send-btn')))
|
||||||
|
.toBeVisible()
|
||||||
|
.withTimeout(3000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Agents ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('navigates to Agents tab and shows the header', async () => {
|
||||||
|
await tapTab('Agents');
|
||||||
|
// Use testID to avoid matching the bottom tab bar label as well
|
||||||
|
await waitFor(element(by.id('agents-screen-title')))
|
||||||
|
.toBeVisible()
|
||||||
|
.withTimeout(5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows the new-agent button in Agents tab', async () => {
|
||||||
|
await waitFor(element(by.id('new-agent-btn')))
|
||||||
|
.toBeVisible()
|
||||||
|
.withTimeout(3000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Hardware ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('navigates to Matériel tab and shows hardware page title', async () => {
|
||||||
|
await tapTab('Matériel');
|
||||||
|
await waitFor(element(by.text('🖥 Matériel')))
|
||||||
|
.toBeVisible()
|
||||||
|
.withTimeout(5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Settings ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('navigates to Réglages tab and shows Settings title', async () => {
|
||||||
|
await tapTab('Réglages');
|
||||||
|
await waitFor(element(by.text('Settings')))
|
||||||
|
.toBeVisible()
|
||||||
|
.withTimeout(5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Back to Models ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('navigates back to Models tab', async () => {
|
||||||
|
await tapTab('Modèles');
|
||||||
|
await waitFor(element(by.text('Mod\u00e8les locaux')).atIndex(0))
|
||||||
|
.toBeVisible()
|
||||||
|
.withTimeout(5000);
|
||||||
|
});
|
||||||
|
});
|
||||||
109
app/e2e/run-detox.js
Normal file
109
app/e2e/run-detox.js
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
#!/usr/bin/env node
|
||||||
|
const { exec, spawn } = require('child_process');
|
||||||
|
|
||||||
|
const ROOT_DIR = __dirname + '/..';
|
||||||
|
|
||||||
|
function run(cmd) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const p = exec(cmd, { cwd: ROOT_DIR, maxBuffer: 1024 * 1024 * 10 });
|
||||||
|
p.stdout.pipe(process.stdout);
|
||||||
|
p.stderr.pipe(process.stderr);
|
||||||
|
p.on('close', code => (code === 0 ? resolve() : reject(new Error(cmd + ' exited with ' + code))));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function adbList() {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
exec('adb devices -l', (err, stdout) => {
|
||||||
|
if (err) return reject(err);
|
||||||
|
resolve(stdout);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if Metro is already listening on port 8081.
|
||||||
|
*/
|
||||||
|
function isMetroRunning() {
|
||||||
|
return new Promise(resolve => {
|
||||||
|
exec('lsof -iTCP:8081 -sTCP:LISTEN -t', (err, stdout) => {
|
||||||
|
resolve(!err && stdout.trim().length > 0);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Start Metro bundler in the background. Returns a cleanup function.
|
||||||
|
*/
|
||||||
|
function startMetro() {
|
||||||
|
console.log('[run-detox] Starting Metro bundler on port 8081...');
|
||||||
|
const metro = spawn('npx', ['react-native', 'start', '--port', '8081', '--reset-cache'], {
|
||||||
|
cwd: ROOT_DIR,
|
||||||
|
stdio: ['ignore', 'pipe', 'pipe'],
|
||||||
|
detached: false,
|
||||||
|
});
|
||||||
|
metro.stdout.on('data', d => process.stdout.write('[metro] ' + d));
|
||||||
|
metro.stderr.on('data', d => process.stderr.write('[metro] ' + d));
|
||||||
|
metro.on('error', err => console.error('[run-detox] Metro error:', err.message));
|
||||||
|
return metro;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wait until Metro is ready (listening on 8081), with a timeout.
|
||||||
|
*/
|
||||||
|
function waitForMetro(timeoutMs = 60000) {
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const start = Date.now();
|
||||||
|
const interval = setInterval(() => {
|
||||||
|
exec('lsof -iTCP:8081 -sTCP:LISTEN -t', (err, stdout) => {
|
||||||
|
if (!err && stdout.trim().length > 0) {
|
||||||
|
clearInterval(interval);
|
||||||
|
resolve();
|
||||||
|
} else if (Date.now() - start > timeoutMs) {
|
||||||
|
clearInterval(interval);
|
||||||
|
reject(new Error('Timed out waiting for Metro to start on port 8081'));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}, 2000);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
let metroProcess = null;
|
||||||
|
try {
|
||||||
|
const out = await adbList();
|
||||||
|
const lines = out.split('\n').slice(1).map(l => l.trim()).filter(Boolean);
|
||||||
|
// filter out emulator entries (emulator-*) and look for 'device' state
|
||||||
|
const attached = lines
|
||||||
|
.map(l => l.split(/\s+/)[0])
|
||||||
|
.filter(id => id && !id.startsWith('emulator-'));
|
||||||
|
|
||||||
|
const useConfig = attached.length > 0 ? 'android.attached+android.debug' : 'android.emu.debug+android.debug';
|
||||||
|
console.log('\n[run-detox] Detected attached devices:', attached.join(', ') || '(none)');
|
||||||
|
console.log('[run-detox] Using configuration:', useConfig, '\n');
|
||||||
|
|
||||||
|
// Ensure Metro is running (required for debug APK to load the JS bundle)
|
||||||
|
const metroAlready = await isMetroRunning();
|
||||||
|
if (metroAlready) {
|
||||||
|
console.log('[run-detox] Metro is already running on port 8081.');
|
||||||
|
} else {
|
||||||
|
metroProcess = startMetro();
|
||||||
|
console.log('[run-detox] Waiting for Metro to be ready (up to 60s)...');
|
||||||
|
await waitForMetro(60000);
|
||||||
|
console.log('[run-detox] Metro is ready.\n');
|
||||||
|
}
|
||||||
|
|
||||||
|
console.log('Building...');
|
||||||
|
await run(`npx detox build -c ${useConfig}`);
|
||||||
|
console.log('Running tests...');
|
||||||
|
await run(`npx detox test -c ${useConfig}`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('\n[run-detox] Error:', err.message || err);
|
||||||
|
process.exit(1);
|
||||||
|
} finally {
|
||||||
|
if (metroProcess) {
|
||||||
|
console.log('\n[run-detox] Stopping Metro bundler...');
|
||||||
|
metroProcess.kill('SIGTERM');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})();
|
||||||
61
app/e2e/settings.e2e.js
Normal file
61
app/e2e/settings.e2e.js
Normal file
@@ -0,0 +1,61 @@
|
|||||||
|
/**
|
||||||
|
* e2e/settings.e2e.js
|
||||||
|
*
|
||||||
|
* Verifies the Settings (Réglages) screen:
|
||||||
|
* - renders correctly
|
||||||
|
* - all four theme buttons are tappable
|
||||||
|
* - theme switches don't crash the app
|
||||||
|
*/
|
||||||
|
const { navigatePastSplash, tapTab } = require('./helpers');
|
||||||
|
|
||||||
|
describe('Settings Screen', () => {
|
||||||
|
beforeAll(async () => {
|
||||||
|
await device.launchApp({ newInstance: true });
|
||||||
|
await navigatePastSplash();
|
||||||
|
await tapTab('Réglages');
|
||||||
|
await waitFor(element(by.text('Settings')))
|
||||||
|
.toBeVisible()
|
||||||
|
.withTimeout(5000);
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Render ──────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('shows the Settings title', async () => {
|
||||||
|
await expect(element(by.text('Settings'))).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('shows all four theme buttons', async () => {
|
||||||
|
await expect(element(by.id('theme-btn-light'))).toBeVisible();
|
||||||
|
await expect(element(by.id('theme-btn-system'))).toBeVisible();
|
||||||
|
await expect(element(by.id('theme-btn-dark'))).toBeVisible();
|
||||||
|
await expect(element(by.id('theme-btn-neon'))).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Theme switching ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
it('switches to Dark theme without crashing', async () => {
|
||||||
|
await element(by.id('theme-btn-dark')).tap();
|
||||||
|
await expect(element(by.text('Settings'))).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switches to Neon theme without crashing', async () => {
|
||||||
|
await element(by.id('theme-btn-neon')).tap();
|
||||||
|
await expect(element(by.text('Settings'))).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switches to Light theme without crashing', async () => {
|
||||||
|
await element(by.id('theme-btn-light')).tap();
|
||||||
|
await expect(element(by.text('Settings'))).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('switches to System theme without crashing', async () => {
|
||||||
|
await element(by.id('theme-btn-system')).tap();
|
||||||
|
await expect(element(by.text('Settings'))).toBeVisible();
|
||||||
|
});
|
||||||
|
|
||||||
|
it('tapping the already-active theme twice does not crash', async () => {
|
||||||
|
await element(by.id('theme-btn-light')).tap();
|
||||||
|
await element(by.id('theme-btn-light')).tap();
|
||||||
|
await expect(element(by.text('Settings'))).toBeVisible();
|
||||||
|
});
|
||||||
|
});
|
||||||
1177
app/package-lock.json
generated
1177
app/package-lock.json
generated
File diff suppressed because it is too large
Load Diff
@@ -7,7 +7,13 @@
|
|||||||
"ios": "react-native run-ios",
|
"ios": "react-native run-ios",
|
||||||
"lint": "eslint .",
|
"lint": "eslint .",
|
||||||
"start": "react-native start",
|
"start": "react-native start",
|
||||||
"test": "jest"
|
"test": "jest",
|
||||||
|
"test:unit": "jest",
|
||||||
|
"test:e2e:build:android": "detox build -c android.emu.debug",
|
||||||
|
"test:e2e:run:android": "detox test -c android.emu.debug",
|
||||||
|
"test:e2e:android": "npm run test:e2e:build:android && npm run test:e2e:run:android",
|
||||||
|
"test:full": "npm run test:unit && npm run test:e2e:android",
|
||||||
|
"test:e2e:auto": "node ./e2e/run-detox.js"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@react-native-async-storage/async-storage": "^2.2.0",
|
"@react-native-async-storage/async-storage": "^2.2.0",
|
||||||
@@ -51,7 +57,9 @@
|
|||||||
"postinstall-postinstall": "^2.1.0",
|
"postinstall-postinstall": "^2.1.0",
|
||||||
"prettier": "2.8.8",
|
"prettier": "2.8.8",
|
||||||
"react-test-renderer": "19.2.3",
|
"react-test-renderer": "19.2.3",
|
||||||
"typescript": "^5.8.3"
|
"typescript": "^5.8.3",
|
||||||
|
"detox": "^20.47.0",
|
||||||
|
"jest-circus": "^29.6.3"
|
||||||
},
|
},
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 22.11.0"
|
"node": ">= 22.11.0"
|
||||||
|
|||||||
@@ -161,6 +161,7 @@ export default function AgentEditor({ visible, agent, onClose }: Props) {
|
|||||||
placeholder="Ex: Expert Python"
|
placeholder="Ex: Expert Python"
|
||||||
placeholderTextColor={colors.textTertiary}
|
placeholderTextColor={colors.textTertiary}
|
||||||
maxLength={60}
|
maxLength={60}
|
||||||
|
testID="agent-name-input"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* System prompt */}
|
{/* System prompt */}
|
||||||
@@ -186,6 +187,7 @@ export default function AgentEditor({ visible, agent, onClose }: Props) {
|
|||||||
style={[styles.saveBtn, saving && styles.saveBtnSaving]}
|
style={[styles.saveBtn, saving && styles.saveBtnSaving]}
|
||||||
onPress={handleSave}
|
onPress={handleSave}
|
||||||
disabled={saving}
|
disabled={saving}
|
||||||
|
testID="agent-save-btn"
|
||||||
>
|
>
|
||||||
{saving ? (
|
{saving ? (
|
||||||
<ActivityIndicator color={colors.surface} size="small" />
|
<ActivityIndicator color={colors.surface} size="small" />
|
||||||
|
|||||||
@@ -87,6 +87,7 @@ export default function AgentsScreen() {
|
|||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
style={styles.deleteBtn}
|
style={styles.deleteBtn}
|
||||||
onPress={() => handleDelete(item)}
|
onPress={() => handleDelete(item)}
|
||||||
|
testID={`delete-agent-${item.name.replace(/\s+/g, '-')}`}
|
||||||
>
|
>
|
||||||
<Text style={styles.deleteBtnText}>✕</Text>
|
<Text style={styles.deleteBtnText}>✕</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
@@ -99,8 +100,8 @@ export default function AgentsScreen() {
|
|||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<View style={styles.header}>
|
<View style={styles.header}>
|
||||||
<Text style={styles.headerTitle}>Agents</Text>
|
<Text style={styles.headerTitle} testID="agents-screen-title">Agents</Text>
|
||||||
<TouchableOpacity style={styles.addBtn} onPress={handleNew}>
|
<TouchableOpacity style={styles.addBtn} onPress={handleNew} testID="new-agent-btn">
|
||||||
<Text style={styles.addBtnText}>+ Nouvel agent</Text>
|
<Text style={styles.addBtnText}>+ Nouvel agent</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
</View>
|
</View>
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export default function ChatInput({
|
|||||||
multiline
|
multiline
|
||||||
maxLength={2000}
|
maxLength={2000}
|
||||||
editable={!disabled}
|
editable={!disabled}
|
||||||
|
testID="chat-input-field"
|
||||||
/>
|
/>
|
||||||
{isGenerating ? (
|
{isGenerating ? (
|
||||||
<TouchableOpacity
|
<TouchableOpacity
|
||||||
@@ -48,6 +49,7 @@ export default function ChatInput({
|
|||||||
style={[themeStyles.sendButton, disabled && themeStyles.sendButtonDisabled]}
|
style={[themeStyles.sendButton, disabled && themeStyles.sendButtonDisabled]}
|
||||||
onPress={onSend}
|
onPress={onSend}
|
||||||
disabled={disabled || !value.trim()}
|
disabled={disabled || !value.trim()}
|
||||||
|
testID="chat-send-btn"
|
||||||
>
|
>
|
||||||
<Text style={themeStyles.sendButtonText}>➤</Text>
|
<Text style={themeStyles.sendButtonText}>➤</Text>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export default function LandingScreen() {
|
|||||||
style={styles.logo}
|
style={styles.logo}
|
||||||
resizeMode="contain"
|
resizeMode="contain"
|
||||||
accessibilityLabel="My Mobile Agent"
|
accessibilityLabel="My Mobile Agent"
|
||||||
|
testID="landing-logo"
|
||||||
/>
|
/>
|
||||||
</View>
|
</View>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -100,6 +100,7 @@ export default function SettingsScreen({}: Props) {
|
|||||||
]}
|
]}
|
||||||
onPress={onPress}
|
onPress={onPress}
|
||||||
activeOpacity={0.8}
|
activeOpacity={0.8}
|
||||||
|
testID={`theme-btn-${it.key}`}
|
||||||
>
|
>
|
||||||
<Text
|
<Text
|
||||||
style={[
|
style={[
|
||||||
|
|||||||
@@ -67,10 +67,7 @@ export const ThemeProvider: React.FC<{
|
|||||||
if (scheme === 'neon') return neonColors;
|
if (scheme === 'neon') return neonColors;
|
||||||
|
|
||||||
// light
|
// light
|
||||||
return {
|
return lightColors as unknown as Record<string, string>;
|
||||||
...(lightColors as Record<string, string>),
|
|
||||||
...(lightOverrides as Record<string, string>),
|
|
||||||
} as Record<string, string>;
|
|
||||||
}, [scheme]);
|
}, [scheme]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|||||||
3
app/src/theme/tokens.ts
Normal file
3
app/src/theme/tokens.ts
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
// Re-export shared design tokens from lightTheme so both
|
||||||
|
// "theme/tokens" and "theme/lightTheme" imports resolve correctly.
|
||||||
|
export { colors, spacing, typography, borderRadius } from './lightTheme';
|
||||||
Reference in New Issue
Block a user