Skip to content
Draft
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
49 changes: 36 additions & 13 deletions examples/vite-app/src/App.tsx
Original file line number Diff line number Diff line change
@@ -1,15 +1,36 @@
import {
AppShell,
AuthProvider,
Button,
DefaultSidebar,
SidebarGroup,
SidebarItem,
SidebarLayout,
useAuth,
type SearchSource,
} from "@tailor-platform/app-shell";
import { createTestAuthClient } from "@tailor-platform/app-shell/testing";
import { ReceiptText } from "lucide-react";
import { searchOrders, searchRecentOrders } from "./fake-search";
import { labels } from "./i18n-labels";

// Use a test auth client to demonstrate login flow (defaults to unauthenticated)
const authClient = createTestAuthClient();

const LoginScreen = () => {
const { login } = useAuth();

return (
<div className="flex h-screen items-center justify-center">
<div className="text-center space-y-4">
<h1 className="text-2xl font-bold">Welcome</h1>
<p className="text-muted-foreground">Please log in to continue.</p>
<Button onClick={() => login()}>Log In</Button>
</div>
</div>
);
};

// Demonstrates multiple search sources in the command palette
const searchSources: SearchSource[] = [
{
Expand All @@ -26,19 +47,21 @@ const searchSources: SearchSource[] = [

const App = () => {
return (
<AppShell title="File-Based Routing Demo" searchSources={searchSources}>
<SidebarLayout
sidebar={
<DefaultSidebar>
<SidebarGroup title={labels.t("navMain")}>
<SidebarItem to="/dashboard" activeMatch="exact" />
<SidebarItem to="/dashboard/orders" icon={<ReceiptText />} />
</SidebarGroup>
<SidebarItem to="/settings" />
</DefaultSidebar>
}
/>
</AppShell>
<AuthProvider client={authClient} guardComponent={LoginScreen}>
<AppShell title="File-Based Routing Demo" searchSources={searchSources}>
<SidebarLayout
sidebar={
<DefaultSidebar>
<SidebarGroup title={labels.t("navMain")}>
<SidebarItem to="/dashboard" activeMatch="exact" />
<SidebarItem to="/dashboard/orders" icon={<ReceiptText />} />
</SidebarGroup>
<SidebarItem to="/settings" />
</DefaultSidebar>
}
/>
</AppShell>
</AuthProvider>
);
};

Expand Down
4 changes: 4 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,10 @@
"./vite-plugin": {
"types": "./dist/vite-plugin.d.ts",
"default": "./dist/vite-plugin.js"
},
"./testing": {
"types": "./dist/testing.d.ts",
"default": "./dist/testing.js"
}
},
"publishConfig": {
Expand Down
177 changes: 177 additions & 0 deletions packages/core/src/testing.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,177 @@
import type { EnhancedAuthClient } from "./contexts/auth-context";
import type { AuthState } from "./contexts/auth-context";
import type {
AuthEvent,
AuthEventListener,
} from "@tailor-platform/auth-public-client";

/**
* Configuration for creating a test auth client.
*/
export interface TestAuthClientConfig {
/** Initial authentication state. Defaults to unauthenticated and ready. */
state?: Partial<AuthState>;
/** The appUri to return from getAppUri(). Defaults to "https://test.example.com". */
appUri?: string;
/**
* A machine token (or any bearer token) to use for API requests.
*
* When provided, `getAuthHeaders()` returns a real Authorization header and
* `fetch()` automatically attaches it to every request. This allows E2E tests
* to bypass OAuth login while still communicating with a real backend.
*/
token?: string;
}

/**
* A test auth client with methods to programmatically control auth state.
*/
export interface TestAuthClient extends EnhancedAuthClient {
/**
* Update the auth state and notify subscribers.
*/
setState(state: Partial<AuthState>): void;
}

/**
* Create a test authentication client for use in tests.
*
* This utility creates an `EnhancedAuthClient` that can be passed directly
* to `AuthProvider` without requiring a real OAuth server. It defaults to
* an authenticated and ready state, making it easy to test components that
* sit behind authentication.
*
* @example
* ```tsx
* import { AuthProvider } from '@tailor-platform/app-shell';
* import { createTestAuthClient } from '@tailor-platform/app-shell/testing';
* import { render } from '@testing-library/react';
*
* const authClient = createTestAuthClient();
*
* render(
* <AuthProvider client={authClient}>
* <YourComponent />
* </AuthProvider>
* );
* ```
*
* @example
* ```tsx
* // Test unauthenticated state
* const authClient = createTestAuthClient({
* state: { isAuthenticated: false },
* });
*
* // Programmatically change state during a test
* authClient.setState({ isAuthenticated: false, isReady: true });
* ```
*/
export function createTestAuthClient(
config?: TestAuthClientConfig,
): TestAuthClient {
const appUri = config?.appUri ?? "https://test.example.com";
const token = config?.token;

let currentState: AuthState = {
isAuthenticated: false,
error: null,
isReady: true,
...config?.state,
};

const listeners: AuthEventListener[] = [];

const notifyListeners = () => {
const event: AuthEvent = { type: "auth_state_changed" };
for (const listener of listeners) {
listener(event);
}
};

const client: TestAuthClient = {
getState() {
return currentState;
},

setState(state: Partial<AuthState>) {
currentState = { ...currentState, ...state };
notifyListeners();
},

login() {
currentState = { isAuthenticated: true, error: null, isReady: true };
notifyListeners();
return Promise.resolve();
},

logout() {
currentState = { isAuthenticated: false, error: null, isReady: true };
notifyListeners();
return Promise.resolve();
},

getAuthUrl() {
return Promise.resolve("https://test.example.com/auth");
},

checkAuthStatus() {
return Promise.resolve(currentState);
},

refreshTokens() {
return Promise.resolve();
},

ready() {
return Promise.resolve();
},

configure() {},

addEventListener(listener: AuthEventListener) {
listeners.push(listener);
return () => {
const index = listeners.indexOf(listener);
if (index >= 0) {
listeners.splice(index, 1);
}
};
},

getAuthHeaders(_url: string | URL, _method?: string) {
return Promise.resolve({
Authorization: `Bearer ${token ?? "test-token"}`,
DPoP: "test-dpop-proof",
});
},

fetch(input: RequestInfo | URL, init?: RequestInit) {
if (!token) {
return globalThis.fetch(input, init);
}
const headers = new Headers(init?.headers);
headers.set("Authorization", `Bearer ${token}`);
return globalThis.fetch(input, { ...init, headers });
},

handleCallback() {
return Promise.resolve();
},

getAppUri() {
return appUri;
},

getCallbackStatusSnapshot() {
return "idle";
},

subscribeCallbackStatus(_listener: () => void) {
// No-op for test client - callback is always idle
return () => {};
},
};

return client;
}
1 change: 1 addition & 0 deletions packages/core/vite.config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ export default defineConfig(({ mode }) => ({
entry: {
"app-shell": "src/index.ts",
"vite-plugin": "src/vite-plugin.ts",
testing: "src/testing.ts",
},
formats: ["es"],
},
Expand Down
Loading