diff --git a/.eslintrc.js b/.eslintrc.js
index c4a026f..72c32d3 100644
--- a/.eslintrc.js
+++ b/.eslintrc.js
@@ -38,6 +38,17 @@ module.exports = {
"@typescript-eslint/no-require-imports": "off",
},
},
+ {
+ // Jest manual mocks are CommonJS modules (module.exports / require) that
+ // run in the Node test environment — give them the right globals so the
+ // `no-undef` / require rules don't flag legitimate mock plumbing.
+ files: ["__mocks__/**"],
+ env: { node: true, jest: true },
+ rules: {
+ "@typescript-eslint/no-require-imports": "off",
+ "@typescript-eslint/no-var-requires": "off",
+ },
+ },
],
ignorePatterns: ["node_modules/", ".expo/", "dist/", "*.config.js", "babel.config.js", "server/hotcrm/"],
};
diff --git a/.github/workflows/eas-build.yml b/.github/workflows/eas-build.yml
index e681d18..f59586d 100644
--- a/.github/workflows/eas-build.yml
+++ b/.github/workflows/eas-build.yml
@@ -31,6 +31,9 @@ jobs:
build:
name: EAS Build (${{ inputs.platform || 'all' }})
runs-on: ubuntu-latest
+ # Skip (neutral, not failed) when no Expo token is configured — EAS needs an
+ # account credential this repo can't provide by default.
+ if: ${{ secrets.EXPO_TOKEN != '' }}
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
diff --git a/.github/workflows/eas-submit.yml b/.github/workflows/eas-submit.yml
index 38d4712..69cbbb4 100644
--- a/.github/workflows/eas-submit.yml
+++ b/.github/workflows/eas-submit.yml
@@ -18,6 +18,8 @@ jobs:
submit:
name: Submit to ${{ inputs.platform == 'ios' && 'App Store' || 'Google Play' }}
runs-on: ubuntu-latest
+ # Skip (neutral, not failed) when no Expo token is configured.
+ if: ${{ secrets.EXPO_TOKEN != '' }}
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
diff --git a/.github/workflows/eas-update.yml b/.github/workflows/eas-update.yml
index 11651f6..e3d0665 100644
--- a/.github/workflows/eas-update.yml
+++ b/.github/workflows/eas-update.yml
@@ -17,6 +17,8 @@ jobs:
update:
name: Publish OTA Update
runs-on: ubuntu-latest
+ # Skip (neutral, not failed) when no Expo token is configured.
+ if: ${{ secrets.EXPO_TOKEN != '' }}
steps:
- uses: actions/checkout@v4
- uses: pnpm/action-setup@v4
diff --git a/.github/workflows/integration.yml b/.github/workflows/integration.yml
index bcda7d0..faf19f4 100644
--- a/.github/workflows/integration.yml
+++ b/.github/workflows/integration.yml
@@ -20,27 +20,46 @@ jobs:
steps:
- uses: actions/checkout@v4
+ # The integration stack (server/integration) is optional and not always
+ # present in the repo. Detect it up front and skip the server steps when
+ # absent so this job is a clean no-op instead of a hard failure on the
+ # missing working directory.
+ - name: Check for integration stack
+ id: stack
+ run: |
+ if [ -d server/integration ]; then
+ echo "present=true" >> "$GITHUB_OUTPUT"
+ else
+ echo "present=false" >> "$GITHUB_OUTPUT"
+ echo "::notice::server/integration not present — skipping server integration tests."
+ fi
+
- uses: pnpm/action-setup@v4
+ if: steps.stack.outputs.present == 'true'
with:
version: 10
- uses: actions/setup-node@v4
+ if: steps.stack.outputs.present == 'true'
with:
node-version: 20
cache: pnpm
# Install app + CLI dependencies
- name: Install dependencies
+ if: steps.stack.outputs.present == 'true'
run: pnpm install
# Pre-compile the stack to a JSON artifact (surfaces any compile error in
# its own step and lets the server boot without an esbuild compile).
- name: Build integration artifact
+ if: steps.stack.outputs.present == 'true'
run: npx objectstack build
working-directory: server/integration
# Start the ObjectStack CLI server (server/integration stack) in background
- name: Start integration server
+ if: steps.stack.outputs.present == 'true'
run: ./scripts/start-integration-server.sh --bg
env:
PORT: 4000
@@ -48,20 +67,22 @@ jobs:
# Wait for server readiness
- name: Wait for server
+ if: steps.stack.outputs.present == 'true'
run: ./scripts/wait-for-server.sh http://localhost:4000/api/v1/auth/get-session 120
# Run integration tests
- name: Run integration tests
+ if: steps.stack.outputs.present == 'true'
run: pnpm test:integration:server
env:
INTEGRATION_SERVER_URL: http://localhost:4000
# Surface the server log for diagnosis if anything above failed
- name: Dump server log
- if: always()
+ if: always() && steps.stack.outputs.present == 'true'
run: cat .integration-server.log || true
# Stop server
- name: Stop integration server
- if: always()
+ if: always() && steps.stack.outputs.present == 'true'
run: ./scripts/stop-integration-server.sh
diff --git a/__tests__/e2e/app-navigation.e2e.test.tsx b/__tests__/e2e/app-navigation.e2e.test.tsx
index ff625ef..fc73fe3 100644
--- a/__tests__/e2e/app-navigation.e2e.test.tsx
+++ b/__tests__/e2e/app-navigation.e2e.test.tsx
@@ -73,13 +73,17 @@ import SearchScreen from "~/app/(tabs)/search";
import AppsScreen from "~/app/(tabs)/apps";
import NotificationsScreen from "~/app/(tabs)/notifications";
import MoreScreen from "~/app/(tabs)/more";
+import { ToastProvider } from "~/components/ui/Toast";
+import { ConfirmProvider } from "~/components/ui/ConfirmDialog";
describe("E2E: App Navigation — Tab Screens", () => {
it("renders Home tab header and empty dashboards state", async () => {
const { getByText, findByText } = render();
- expect(getByText("Dashboard")).toBeTruthy();
- expect(getByText("Welcome back. Here's your overview.")).toBeTruthy();
+ // Home leads with a time-of-day greeting (asserted via the stable subtitle)
+ // and surfaces the AI assistant entry.
+ expect(getByText("Here's your overview.")).toBeTruthy();
+ expect(getByText("AI Assistant")).toBeTruthy();
// Apps publish no dashboards in this mock, so Home shows its empty state.
expect(await findByText("No Dashboards")).toBeTruthy();
});
@@ -87,12 +91,8 @@ describe("E2E: App Navigation — Tab Screens", () => {
it("renders Search tab with search input", () => {
const { getByPlaceholderText, getByText } = render();
- expect(
- getByPlaceholderText("Search objects, records..."),
- ).toBeTruthy();
- expect(
- getByText("Search across all your objects and records"),
- ).toBeTruthy();
+ expect(getByPlaceholderText("Search across all records…")).toBeTruthy();
+ expect(getByText("Search across all your records")).toBeTruthy();
expect(getByText("Type to start searching")).toBeTruthy();
});
@@ -118,19 +118,26 @@ describe("E2E: App Navigation — Tab Screens", () => {
});
it("renders More tab with menu sections", () => {
- const { getByText } = render();
+ // MoreScreen consumes the Toast + Confirm contexts (sign-out flow).
+ const { getByText } = render(
+
+
+
+
+ ,
+ );
expect(getByText("Test User")).toBeTruthy();
expect(getByText("test@example.com")).toBeTruthy();
+ // Section headers.
+ expect(getByText("Account")).toBeTruthy();
+ expect(getByText("Assistant")).toBeTruthy();
+ expect(getByText("Automation")).toBeTruthy();
expect(getByText("Preferences")).toBeTruthy();
- expect(getByText("Appearance")).toBeTruthy();
+ // Menu items.
+ expect(getByText("Account & Security")).toBeTruthy();
+ expect(getByText("AI Assistant")).toBeTruthy();
expect(getByText("Language")).toBeTruthy();
- expect(getByText("Security")).toBeTruthy();
- expect(getByText("Security & Privacy")).toBeTruthy();
- expect(getByText("Settings")).toBeTruthy();
- expect(getByText("Support")).toBeTruthy();
- expect(getByText("Help & Support")).toBeTruthy();
- expect(getByText("About")).toBeTruthy();
expect(getByText("Sign Out")).toBeTruthy();
});
});
diff --git a/__tests__/e2e/auth-flow.e2e.test.tsx b/__tests__/e2e/auth-flow.e2e.test.tsx
index b25cdb0..8f1727a 100644
--- a/__tests__/e2e/auth-flow.e2e.test.tsx
+++ b/__tests__/e2e/auth-flow.e2e.test.tsx
@@ -6,7 +6,6 @@
*/
import React from "react";
import { render, fireEvent, waitFor } from "@testing-library/react-native";
-import { Alert } from "react-native";
/* ---- Mocks ---- */
@@ -40,8 +39,6 @@ jest.mock("~/stores/server-store", () => ({
selector({ ssoProviders: ["google"] }),
}));
-jest.spyOn(Alert, "alert");
-
import SignInScreen from "~/app/(auth)/sign-in";
describe("E2E: Authentication Flow", () => {
@@ -67,10 +64,8 @@ describe("E2E: Authentication Flow", () => {
fireEvent.press(getByText("Sign In"));
- expect(Alert.alert).toHaveBeenCalledWith(
- "Error",
- "Please fill in all fields.",
- );
+ // The screen surfaces an inline error rather than a native Alert.
+ expect(getByText("Please enter your email and password.")).toBeTruthy();
expect(mockSignInEmail).not.toHaveBeenCalled();
});
@@ -119,10 +114,7 @@ describe("E2E: Authentication Flow", () => {
fireEvent.press(getByText("Sign In"));
await waitFor(() => {
- expect(Alert.alert).toHaveBeenCalledWith(
- "Sign In Failed",
- "Invalid credentials",
- );
+ expect(getByText("Invalid credentials")).toBeTruthy();
});
expect(mockReplace).not.toHaveBeenCalled();
});
@@ -143,10 +135,7 @@ describe("E2E: Authentication Flow", () => {
fireEvent.press(getByText("Sign In"));
await waitFor(() => {
- expect(Alert.alert).toHaveBeenCalledWith(
- "Error",
- "Something went wrong. Please try again.",
- );
+ expect(getByText("Something went wrong. Please try again.")).toBeTruthy();
});
});
diff --git a/__tests__/e2e/e2e.setup.ts b/__tests__/e2e/e2e.setup.ts
new file mode 100644
index 0000000..ad1c76e
--- /dev/null
+++ b/__tests__/e2e/e2e.setup.ts
@@ -0,0 +1,10 @@
+/**
+ * E2E test setup.
+ *
+ * Initialize i18n so the rendered screens show their real (English) copy rather
+ * than raw translation keys — the e2e suite renders full screens that call
+ * `useTranslation`, and without an initialized instance `t(key)` returns the
+ * key. Importing the app's i18n module runs its synchronous inline-resource
+ * init (no async backend), so translations resolve immediately.
+ */
+import "~/lib/i18n";
diff --git a/__tests__/e2e/record-list.e2e.test.tsx b/__tests__/e2e/record-list.e2e.test.tsx
index f913b1f..66d2084 100644
--- a/__tests__/e2e/record-list.e2e.test.tsx
+++ b/__tests__/e2e/record-list.e2e.test.tsx
@@ -27,8 +27,8 @@ let mockAppDiscoveryReturn: {
refetch: jest.Mock;
};
-jest.mock("~/hooks/useAppDiscovery", () => ({
- useAppDiscovery: () => mockAppDiscoveryReturn,
+jest.mock("~/hooks/useApps", () => ({
+ useApps: () => mockAppDiscoveryReturn,
}));
import AppsScreen from "~/app/(tabs)/apps";
@@ -93,7 +93,7 @@ describe("E2E: Record List — App Discovery & Navigation", () => {
const { getByText } = render();
- expect(getByText("Loading apps…")).toBeTruthy();
+ expect(getByText("Loading your apps…")).toBeTruthy();
});
it("shows error state with retry button", () => {
diff --git a/__tests__/lib/formatting.test.ts b/__tests__/lib/formatting.test.ts
index 3bcd897..3d4c5a1 100644
--- a/__tests__/lib/formatting.test.ts
+++ b/__tests__/lib/formatting.test.ts
@@ -4,6 +4,7 @@ import {
formatNumber,
formatPercent,
formatCurrency,
+ formatByPattern,
} from "~/lib/formatting";
describe("formatDate", () => {
@@ -66,3 +67,42 @@ describe("formatCurrency", () => {
expect(result).toMatch(/€|EUR/);
});
});
+
+describe("formatDateTime — invalid input", () => {
+ it("returns the raw value for an unparseable date", () => {
+ expect(formatDateTime("nope")).toBe("nope");
+ });
+});
+
+describe("formatByPattern", () => {
+ it("falls back to a grouped number when no pattern is given", () => {
+ expect(formatByPattern(1234)).toBe("1,234");
+ });
+
+ it("returns an em dash for non-finite values", () => {
+ expect(formatByPattern(Infinity, "$0,0")).toBe("—");
+ expect(formatByPattern(NaN)).toBe("—");
+ });
+
+ it("formats a percent pattern with the declared fraction digits", () => {
+ expect(formatByPattern(50, "0%")).toBe("50%");
+ expect(formatByPattern(12.5, "0.0%")).toBe("12.5%");
+ });
+
+ it("formats a currency pattern", () => {
+ expect(formatByPattern(1000, "$0,0")).toContain("$");
+ expect(formatByPattern(1000, "$0,0")).toContain("1,000");
+ expect(formatByPattern(9.99, "$0,0.00")).toContain("9.99");
+ });
+
+ it("abbreviates with a compact notation pattern", () => {
+ expect(formatByPattern(1500, "0a")).toMatch(/1\.5\s?K/i);
+ const m = formatByPattern(1_500_000, "$0.0a");
+ expect(m).toContain("$");
+ expect(m).toMatch(/1\.5\s?M/i);
+ });
+
+ it("honours fraction digits in a plain decimal pattern", () => {
+ expect(formatByPattern(1234.5, "0,0.0")).toBe("1,234.5");
+ });
+});
diff --git a/__tests__/lib/query-builder.test.ts b/__tests__/lib/query-builder.test.ts
index f9a2dcf..e97ae3f 100644
--- a/__tests__/lib/query-builder.test.ts
+++ b/__tests__/lib/query-builder.test.ts
@@ -9,6 +9,7 @@ import {
buildProjection,
OPERATOR_META,
resolveFilterMacro,
+ mongoFilterToAst,
type FilterOperator,
} from "~/lib/query-builder";
@@ -233,4 +234,85 @@ describe("resolveFilterMacro — week tokens", () => {
it("leaves an unknown macro untouched (visibly inert, not silently zero)", () => {
expect(resolveFilterMacro("{not_a_real_macro}")).toBe("{not_a_real_macro}");
});
+
+ it("passes through non-string and non-macro values unchanged", () => {
+ expect(resolveFilterMacro(42)).toBe(42);
+ expect(resolveFilterMacro("literal")).toBe("literal");
+ });
+
+ it("resolves day/relative tokens", () => {
+ const day = 86_400_000;
+ const today = resolveFilterMacro("{today}") as number;
+ expect(typeof today).toBe("number");
+ expect(resolveFilterMacro("{yesterday}")).toBe(today - day);
+ expect(resolveFilterMacro("{tomorrow}")).toBe(today + day);
+ expect(typeof resolveFilterMacro("{now}")).toBe("number");
+ const ago = resolveFilterMacro("{3_days_ago}") as number;
+ expect(ago).toBeLessThan(Date.now());
+ expect(typeof resolveFilterMacro("{last_2_months}")).toBe("number");
+ expect(typeof resolveFilterMacro("{1_year_ago}")).toBe("number");
+ });
+
+ it("resolves period-boundary tokens", () => {
+ for (const tok of [
+ "{current_year_start}",
+ "{current_year_end}",
+ "{current_month_start}",
+ "{current_month_end}",
+ "{current_quarter_start}",
+ "{current_quarter_end}",
+ ]) {
+ expect(typeof resolveFilterMacro(tok)).toBe("number");
+ }
+ });
+});
+
+describe("mongoFilterToAst", () => {
+ it("returns undefined for an empty or absent filter", () => {
+ expect(mongoFilterToAst(undefined)).toBeUndefined();
+ expect(mongoFilterToAst(null)).toBeUndefined();
+ expect(mongoFilterToAst({})).toBeUndefined();
+ });
+
+ it("translates a bare value to an equality node", () => {
+ expect(mongoFilterToAst({ status: "open" })).toEqual(["status", "=", "open"]);
+ });
+
+ it("translates Mongo operators to the AST symbol vocabulary", () => {
+ expect(mongoFilterToAst({ age: { $gte: 18 } })).toEqual(["age", ">=", 18]);
+ expect(mongoFilterToAst({ tag: { $in: ["a", "b"] } })).toEqual([
+ "tag",
+ "in",
+ ["a", "b"],
+ ]);
+ });
+
+ it("ANDs multiple operators on one field", () => {
+ expect(mongoFilterToAst({ age: { $gte: 18, $lte: 65 } })).toEqual([
+ "and",
+ ["age", ">=", 18],
+ ["age", "<=", 65],
+ ]);
+ });
+
+ it("ANDs multiple fields into a compound node", () => {
+ expect(
+ mongoFilterToAst({ is_completed: true, priority: "high" }),
+ ).toEqual([
+ "and",
+ ["is_completed", "=", true],
+ ["priority", "=", "high"],
+ ]);
+ });
+
+ it("resolves date macros inside conditions", () => {
+ const ast = mongoFilterToAst({ created_at: { $gte: "{today}" } }) as unknown[];
+ expect(ast[0]).toBe("created_at");
+ expect(ast[1]).toBe(">=");
+ expect(typeof ast[2]).toBe("number");
+ });
+
+ it("skips unknown operators", () => {
+ expect(mongoFilterToAst({ x: { $weird: 1 } })).toBeUndefined();
+ });
});
diff --git a/jest.e2e.config.js b/jest.e2e.config.js
index 47d4061..a022c11 100644
--- a/jest.e2e.config.js
+++ b/jest.e2e.config.js
@@ -13,5 +13,11 @@ module.exports = {
...baseConfig,
testMatch: ["**/__tests__/e2e/**/*.e2e.test.(ts|tsx)"],
testPathIgnorePatterns: ["/node_modules/", "/server/hotcrm/"],
+ // Initialize i18n on top of the base setup so rendered screens show their
+ // real copy instead of raw translation keys.
+ setupFilesAfterEnv: [
+ ...baseConfig.setupFilesAfterEnv,
+ "/__tests__/e2e/e2e.setup.ts",
+ ],
testTimeout: 10_000,
};