diff --git a/packages/core/package.json b/packages/core/package.json
index 74dbecbe..9f5768df 100644
--- a/packages/core/package.json
+++ b/packages/core/package.json
@@ -27,10 +27,10 @@
"format": "prettier --write \"src/**/*.ts\" \"tests/**/*.ts\"",
"format:check": "prettier --check \"src/**/*.ts\" \"tests/**/*.ts\"",
"clean": "rimraf dist",
- "test:unit": "vitest run tests/unit",
- "test:unit:watch": "vitest tests/unit",
- "test:integration": "vitest run tests/integration",
- "test:integration:watch": "vitest tests/integration",
+ "test:unit": "vitest run src",
+ "test:unit:watch": "vitest tests",
+ "test:integration": "vitest run tests",
+ "test:integration:watch": "vitest integration",
"test": "vitest run",
"publish:tags": "sh -c 'TAG=\"${npm_package_name}@${npm_package_version}\"; git tag --list \"$TAG\" | grep . || git tag \"$TAG\"; git push origin \"$TAG\"'",
"release": "pnpm run build && pnpm pack --pack-destination --pack-destination ../../releases/"
@@ -60,6 +60,7 @@
"tsup": "catalog:",
"typescript": "catalog:",
"vite": "catalog:",
+ "vitest-tsconfig-paths": "catalog:",
"vitest": "catalog:"
}
}
diff --git a/packages/core/src/auth.test.ts b/packages/core/src/auth.test.ts
new file mode 100644
index 00000000..53649a10
--- /dev/null
+++ b/packages/core/src/auth.test.ts
@@ -0,0 +1,802 @@
+import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
+import { signInWithEmailAndPassword, createUserWithEmailAndPassword, signInWithPhoneNumber, confirmPhoneNumber, sendPasswordResetEmail, sendSignInLinkToEmail, signInWithEmailLink, signInAnonymously, signInWithProvider, completeEmailLinkSignIn, } from "./auth";
+import type { FirebaseUIConfiguration } from "./config";
+
+// Mock the external dependencies
+vi.mock("firebase/auth", () => ({
+ signInWithCredential: vi.fn(),
+ createUserWithEmailAndPassword: vi.fn(),
+ signInWithPhoneNumber: vi.fn(),
+ sendPasswordResetEmail: vi.fn(),
+ sendSignInLinkToEmail: vi.fn(),
+ signInAnonymously: vi.fn(),
+ signInWithRedirect: vi.fn(),
+ isSignInWithEmailLink: vi.fn(),
+ EmailAuthProvider: {
+ credential: vi.fn(),
+ credentialWithLink: vi.fn(),
+ },
+ PhoneAuthProvider: {
+ credential: vi.fn(),
+ },
+ linkWithCredential: vi.fn(),
+}));
+
+vi.mock("./behaviors", () => ({
+ hasBehavior: vi.fn(),
+ getBehavior: vi.fn(),
+}));
+
+vi.mock("./errors", () => ({
+ handleFirebaseError: vi.fn(),
+}));
+
+// Import the mocked functions
+import { signInWithCredential, EmailAuthProvider, PhoneAuthProvider, createUserWithEmailAndPassword as _createUserWithEmailAndPassword, signInWithPhoneNumber as _signInWithPhoneNumber, sendPasswordResetEmail as _sendPasswordResetEmail, sendSignInLinkToEmail as _sendSignInLinkToEmail, signInAnonymously as _signInAnonymously, signInWithRedirect, isSignInWithEmailLink as _isSignInWithEmailLink, UserCredential, Auth, ConfirmationResult, AuthProvider } from "firebase/auth";
+import { hasBehavior, getBehavior } from "./behaviors";
+import { handleFirebaseError } from "./errors";
+import { FirebaseError } from "firebase/app";
+
+import { createMockUI } from "~/tests/utils";
+
+// TODO(ehesp): Add tests for handlePendingCredential.
+
+describe("signInWithEmailAndPassword", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("should update state and call signInWithCredential with no behavior", async () => {
+ const mockUI = createMockUI();
+ const email = "test@example.com";
+ const password = "password123";
+
+ const credential = EmailAuthProvider.credential(email, password);
+ vi.mocked(hasBehavior).mockReturnValue(false);
+ vi.mocked(EmailAuthProvider.credential).mockReturnValue(credential);
+ vi.mocked(signInWithCredential).mockResolvedValue({ providerId: "password" } as UserCredential);
+
+ const result = await signInWithEmailAndPassword(mockUI, email, password);
+
+ expect(hasBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential");
+
+ // Calls pending pre-signInWithCredential call, then idle after.
+ expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]);
+
+ expect(signInWithCredential).toHaveBeenCalledWith(mockUI.auth, credential);
+ expect(signInWithCredential).toHaveBeenCalledTimes(1);
+
+ // Assert that the result is a valid UserCredential.
+ expect(result.providerId).toBe("password");
+ });
+
+ it('should call the autoUpgradeAnonymousCredential behavior if enabled and return a value', async () => {
+ const mockUI = createMockUI();
+ const email = "test@example.com";
+ const password = "password123";
+
+ const credential = EmailAuthProvider.credential(email, password);
+ vi.mocked(hasBehavior).mockReturnValue(true);
+ const mockBehavior = vi.fn().mockResolvedValue({ providerId: "password" } as UserCredential);
+ vi.mocked(getBehavior).mockReturnValue(mockBehavior);
+
+
+ const result = await signInWithEmailAndPassword(mockUI, email, password);
+
+ expect(hasBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential");
+ expect(getBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential");
+
+ expect(mockBehavior).toHaveBeenCalledWith(mockUI, credential);
+ expect(result.providerId).toBe("password");
+
+ // Only the `finally` block is called here.
+ expect(vi.mocked(mockUI.setState).mock.calls).toEqual([['idle']]);
+ });
+
+ it('should call the autoUpgradeAnonymousCredential behavior if enabled and handle no result from the behavior', async () => {
+ const mockUI = createMockUI();
+ const email = "test@example.com";
+ const password = "password123";
+
+ const credential = EmailAuthProvider.credential(email, password);
+ vi.mocked(hasBehavior).mockReturnValue(true);
+ const mockBehavior = vi.fn().mockResolvedValue(undefined);
+ vi.mocked(getBehavior).mockReturnValue(mockBehavior);
+
+
+ await signInWithEmailAndPassword(mockUI, email, password);
+
+ expect(hasBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential");
+ expect(getBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential");
+
+ expect(mockBehavior).toHaveBeenCalledWith(mockUI, credential);
+
+ expect(signInWithCredential).toHaveBeenCalledWith(mockUI.auth, credential);
+ expect(signInWithCredential).toHaveBeenCalledTimes(1);
+
+ // Calls pending pre-signInWithCredential call, then idle after.
+ expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]);
+ });
+
+ it('should call handleFirebaseError if an error is thrown', async () => {
+ const mockUI = createMockUI();
+ const email = "test@example.com";
+ const password = "password123";
+
+ vi.mocked(hasBehavior).mockReturnValue(false);
+
+ const error = new FirebaseError('foo/bar', 'Foo bar');
+
+ vi.mocked(signInWithCredential).mockRejectedValue(error);
+
+ await signInWithEmailAndPassword(mockUI, email, password);
+
+ expect(handleFirebaseError).toHaveBeenCalledWith(mockUI, error);
+ expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"],["idle"]]);
+ });
+});
+
+describe("createUserWithEmailAndPassword", () => {
+
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("should update state and call createUserWithEmailAndPassword with no behavior", async () => {
+ const mockUI = createMockUI();
+ const email = "test@example.com";
+ const password = "password123";
+
+ const credential = EmailAuthProvider.credential(email, password);
+ vi.mocked(hasBehavior).mockReturnValue(false);
+ vi.mocked(EmailAuthProvider.credential).mockReturnValue(credential);
+ vi.mocked(_createUserWithEmailAndPassword).mockResolvedValue({ providerId: "password" } as UserCredential);
+
+ const result = await createUserWithEmailAndPassword(mockUI, email, password);
+
+ expect(hasBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential");
+
+ // Calls pending pre-createUserWithEmailAndPassword call, then idle after.
+ expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]);
+
+ expect(_createUserWithEmailAndPassword).toHaveBeenCalledWith(mockUI.auth, email, password);
+ expect(_createUserWithEmailAndPassword).toHaveBeenCalledTimes(1);
+
+ // Assert that the result is a valid UserCredential.
+ expect(result.providerId).toBe("password");
+ });
+
+ it('should call the autoUpgradeAnonymousCredential behavior if enabled and return a value', async () => {
+ const mockUI = createMockUI();
+ const email = "test@example.com";
+ const password = "password123";
+
+ const credential = EmailAuthProvider.credential(email, password);
+ vi.mocked(hasBehavior).mockReturnValue(true);
+ const mockBehavior = vi.fn().mockResolvedValue({ providerId: "password" } as UserCredential);
+ vi.mocked(getBehavior).mockReturnValue(mockBehavior);
+
+ const result = await createUserWithEmailAndPassword(mockUI, email, password);
+
+ expect(hasBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential");
+ expect(getBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential");
+
+ expect(mockBehavior).toHaveBeenCalledWith(mockUI, credential);
+ expect(result.providerId).toBe("password");
+
+ // Only the `finally` block is called here.
+ expect(vi.mocked(mockUI.setState).mock.calls).toEqual([['idle']]);
+ });
+
+ it('should call the autoUpgradeAnonymousCredential behavior if enabled and handle no result from the behavior', async () => {
+ const mockUI = createMockUI();
+ const email = "test@example.com";
+ const password = "password123";
+
+ const credential = EmailAuthProvider.credential(email, password);
+ vi.mocked(hasBehavior).mockReturnValue(true);
+ const mockBehavior = vi.fn().mockResolvedValue(undefined);
+ vi.mocked(getBehavior).mockReturnValue(mockBehavior);
+
+ await createUserWithEmailAndPassword(mockUI, email, password);
+
+ expect(hasBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential");
+ expect(getBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential");
+
+ expect(mockBehavior).toHaveBeenCalledWith(mockUI, credential);
+
+ expect(_createUserWithEmailAndPassword).toHaveBeenCalledWith(mockUI.auth, email, password);
+ expect(_createUserWithEmailAndPassword).toHaveBeenCalledTimes(1);
+
+ // Calls pending pre-createUserWithEmailAndPassword call, then idle after.
+ expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]);
+ });
+
+ it('should call handleFirebaseError if an error is thrown', async () => {
+ const mockUI = createMockUI();
+ const email = "test@example.com";
+ const password = "password123";
+
+ vi.mocked(hasBehavior).mockReturnValue(false);
+
+ const error = new FirebaseError('foo/bar', 'Foo bar');
+
+ vi.mocked(_createUserWithEmailAndPassword).mockRejectedValue(error);
+
+ await createUserWithEmailAndPassword(mockUI, email, password);
+
+ expect(handleFirebaseError).toHaveBeenCalledWith(mockUI, error);
+ expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"],["idle"]]);
+ });
+});
+
+describe("signInWithPhoneNumber", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("should update state and call signInWithPhoneNumber successfully", async () => {
+ const mockUI = createMockUI();
+ const phoneNumber = "+1234567890";
+ const mockRecaptchaVerifier = {} as any;
+ const mockConfirmationResult = {
+ verificationId: "test-verification-id",
+ confirm: vi.fn(),
+ } as any;
+
+ vi.mocked(_signInWithPhoneNumber).mockResolvedValue(mockConfirmationResult);
+
+ const result = await signInWithPhoneNumber(mockUI, phoneNumber, mockRecaptchaVerifier);
+
+ // Verify state management
+ expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]);
+
+ // Verify the Firebase function was called with correct parameters
+ expect(_signInWithPhoneNumber).toHaveBeenCalledWith(mockUI.auth, phoneNumber, mockRecaptchaVerifier);
+ expect(_signInWithPhoneNumber).toHaveBeenCalledTimes(1);
+
+ // Verify the result
+ expect(result).toEqual(mockConfirmationResult);
+ });
+
+ it("should call handleFirebaseError if an error is thrown", async () => {
+ const mockUI = createMockUI();
+ const phoneNumber = "+1234567890";
+ const mockRecaptchaVerifier = {} as any;
+ const error = new FirebaseError('auth/invalid-phone-number', 'Invalid phone number');
+
+ vi.mocked(_signInWithPhoneNumber).mockRejectedValue(error);
+
+ await signInWithPhoneNumber(mockUI, phoneNumber, mockRecaptchaVerifier);
+
+ // Verify error handling
+ expect(handleFirebaseError).toHaveBeenCalledWith(mockUI, error);
+
+ // Verify state management still happens
+ expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]);
+ });
+
+ it("should handle recaptcha verification errors", async () => {
+ const mockUI = createMockUI();
+ const phoneNumber = "+1234567890";
+ const mockRecaptchaVerifier = {} as any;
+ const error = new Error("reCAPTCHA verification failed");
+
+ vi.mocked(_signInWithPhoneNumber).mockRejectedValue(error);
+
+ await signInWithPhoneNumber(mockUI, phoneNumber, mockRecaptchaVerifier);
+
+ // Verify error handling
+ expect(handleFirebaseError).toHaveBeenCalledWith(mockUI, error);
+
+ // Verify state management
+ expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]);
+ });
+});
+
+describe("confirmPhoneNumber", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("should update state and call signInWithCredential with no behavior", async () => {
+ const mockUI = createMockUI({
+ auth: { currentUser: null } as Auth
+ });
+ const confirmationResult = { verificationId: "test-verification-id" } as ConfirmationResult;
+ const verificationCode = "123456";
+
+ const credential = PhoneAuthProvider.credential(confirmationResult.verificationId, verificationCode);
+ vi.mocked(hasBehavior).mockReturnValue(false);
+ vi.mocked(PhoneAuthProvider.credential).mockReturnValue(credential);
+ vi.mocked(signInWithCredential).mockResolvedValue({ providerId: "phone" } as UserCredential);
+
+ const result = await confirmPhoneNumber(mockUI, confirmationResult, verificationCode);
+
+ // Since currentUser is null, the behavior should not called.
+ expect(hasBehavior).toHaveBeenCalledTimes(0);
+
+ // Calls pending pre-signInWithCredential call, then idle after.
+ expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]);
+
+ expect(signInWithCredential).toHaveBeenCalledWith(mockUI.auth, credential);
+ expect(signInWithCredential).toHaveBeenCalledTimes(1);
+
+ // Assert that the result is a valid UserCredential.
+ expect(result.providerId).toBe("phone");
+ });
+
+ it("should call autoUpgradeAnonymousCredential behavior when user is anonymous", async () => {
+ const mockUI = createMockUI({
+ auth: { currentUser: { isAnonymous: true } } as Auth
+ });
+ const confirmationResult = { verificationId: "test-verification-id" } as ConfirmationResult;
+ const verificationCode = "123456";
+
+ const credential = PhoneAuthProvider.credential(confirmationResult.verificationId, verificationCode);
+ vi.mocked(hasBehavior).mockReturnValue(true);
+ vi.mocked(PhoneAuthProvider.credential).mockReturnValue(credential);
+ const mockBehavior = vi.fn().mockResolvedValue({ providerId: "phone" } as UserCredential);
+ vi.mocked(getBehavior).mockReturnValue(mockBehavior);
+
+ const result = await confirmPhoneNumber(mockUI, confirmationResult, verificationCode);
+
+ expect(hasBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential");
+ expect(getBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential");
+
+ expect(mockBehavior).toHaveBeenCalledWith(mockUI, credential);
+ expect(result.providerId).toBe("phone");
+
+ // Only the `finally` block is called here.
+ expect(vi.mocked(mockUI.setState).mock.calls).toEqual([['idle']]);
+ });
+
+ it("should not call behavior when user is not anonymous", async () => {
+ const mockUI = createMockUI({
+ auth: { currentUser: { isAnonymous: false } } as Auth
+ });
+ const confirmationResult = { verificationId: "test-verification-id" } as ConfirmationResult;
+ const verificationCode = "123456";
+
+ const credential = PhoneAuthProvider.credential(confirmationResult.verificationId, verificationCode);
+ vi.mocked(PhoneAuthProvider.credential).mockReturnValue(credential);
+ vi.mocked(signInWithCredential).mockResolvedValue({ providerId: "phone" } as UserCredential);
+
+ const result = await confirmPhoneNumber(mockUI, confirmationResult, verificationCode);
+
+ // Behavior should not be called when user is not anonymous
+ expect(hasBehavior).not.toHaveBeenCalled();
+
+ // Should proceed with normal sign-in flow
+ expect(signInWithCredential).toHaveBeenCalledWith(mockUI.auth, credential);
+ expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]);
+ expect(result.providerId).toBe("phone");
+ });
+
+ it("should not call behavior when user is null", async () => {
+ const mockUI = createMockUI({
+ auth: { currentUser: null } as Auth
+ });
+ const confirmationResult = { verificationId: "test-verification-id" } as ConfirmationResult;
+ const verificationCode = "123456";
+
+ const credential = PhoneAuthProvider.credential(confirmationResult.verificationId, verificationCode);
+ vi.mocked(PhoneAuthProvider.credential).mockReturnValue(credential);
+ vi.mocked(signInWithCredential).mockResolvedValue({ providerId: "phone" } as UserCredential);
+
+ const result = await confirmPhoneNumber(mockUI, confirmationResult, verificationCode);
+
+ // Behavior should not be called when user is null
+ expect(hasBehavior).not.toHaveBeenCalled();
+
+ // Should proceed with normal sign-in flow
+ expect(signInWithCredential).toHaveBeenCalledWith(mockUI.auth, credential);
+ expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]);
+ expect(result.providerId).toBe("phone");
+ });
+
+ it("should fall back to normal sign-in when behavior returns undefined", async () => {
+ const mockUI = createMockUI({
+ auth: { currentUser: { isAnonymous: true } } as Auth
+ });
+ const confirmationResult = { verificationId: "test-verification-id" } as ConfirmationResult;
+ const verificationCode = "123456";
+
+ const credential = PhoneAuthProvider.credential(confirmationResult.verificationId, verificationCode);
+ vi.mocked(hasBehavior).mockReturnValue(true);
+ vi.mocked(PhoneAuthProvider.credential).mockReturnValue(credential);
+ const mockBehavior = vi.fn().mockResolvedValue(undefined);
+ vi.mocked(getBehavior).mockReturnValue(mockBehavior);
+
+ await confirmPhoneNumber(mockUI, confirmationResult, verificationCode);
+
+ expect(hasBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential");
+ expect(getBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential");
+
+ expect(mockBehavior).toHaveBeenCalledWith(mockUI, credential);
+
+ expect(signInWithCredential).toHaveBeenCalledWith(mockUI.auth, credential);
+ expect(signInWithCredential).toHaveBeenCalledTimes(1);
+
+ // Calls pending pre-signInWithCredential call, then idle after.
+ expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]);
+ });
+
+ it("should call handleFirebaseError if an error is thrown", async () => {
+ const mockUI = createMockUI({
+ auth: { currentUser: null } as Auth
+ });
+ const confirmationResult = { verificationId: "test-verification-id" } as ConfirmationResult;
+ const verificationCode = "123456";
+
+ const error = new FirebaseError('auth/invalid-verification-code', 'Invalid verification code');
+
+ vi.mocked(signInWithCredential).mockRejectedValue(error);
+
+ await confirmPhoneNumber(mockUI, confirmationResult, verificationCode);
+
+ expect(handleFirebaseError).toHaveBeenCalledWith(mockUI, error);
+ expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]);
+ });
+});
+
+describe("sendPasswordResetEmail", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("should update state and call sendPasswordResetEmail successfully", async () => {
+ const mockUI = createMockUI();
+ const email = "test@example.com";
+
+ vi.mocked(_sendPasswordResetEmail).mockResolvedValue(undefined);
+
+ await sendPasswordResetEmail(mockUI, email);
+
+ // Verify state management
+ expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]);
+
+ // Verify the Firebase function was called with correct parameters
+ expect(_sendPasswordResetEmail).toHaveBeenCalledWith(mockUI.auth, email);
+ expect(_sendPasswordResetEmail).toHaveBeenCalledTimes(1);
+ });
+
+ it("should call handleFirebaseError if an error is thrown", async () => {
+ const mockUI = createMockUI();
+ const email = "test@example.com";
+ const error = new FirebaseError('auth/user-not-found', 'User not found');
+
+ vi.mocked(_sendPasswordResetEmail).mockRejectedValue(error);
+
+ await sendPasswordResetEmail(mockUI, email);
+
+ // Verify error handling
+ expect(handleFirebaseError).toHaveBeenCalledWith(mockUI, error);
+
+ // Verify state management still happens
+ expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]);
+ });
+});
+
+ describe("sendSignInLinkToEmail", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ // Mock window.location.href
+ Object.defineProperty(window, 'location', {
+ value: { href: 'https://example.com' },
+ writable: true
+ });
+ });
+
+ afterEach(() => {
+ // Clean up localStorage after each test
+ window.localStorage.clear();
+ });
+
+ it("should update state and call sendSignInLinkToEmail successfully", async () => {
+ const mockUI = createMockUI();
+ const email = "test@example.com";
+
+ vi.mocked(_sendSignInLinkToEmail).mockResolvedValue(undefined);
+
+ await sendSignInLinkToEmail(mockUI, email);
+
+ expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]);
+
+ const expectedActionCodeSettings = {
+ url: 'https://example.com',
+ handleCodeInApp: true,
+ };
+ expect(_sendSignInLinkToEmail).toHaveBeenCalledWith(mockUI.auth, email, expectedActionCodeSettings);
+ expect(_sendSignInLinkToEmail).toHaveBeenCalledTimes(1);
+
+ // Verify email is stored in localStorage
+ expect(window.localStorage.getItem("emailForSignIn")).toBe(email);
+ });
+
+ it("should call handleFirebaseError if an error is thrown", async () => {
+ const mockUI = createMockUI();
+ const email = "test@example.com";
+ const error = new FirebaseError('auth/invalid-email', 'Invalid email address');
+
+ vi.mocked(_sendSignInLinkToEmail).mockRejectedValue(error);
+
+ await sendSignInLinkToEmail(mockUI, email);
+
+ // Verify error handling
+ expect(handleFirebaseError).toHaveBeenCalledWith(mockUI, error);
+
+ // Verify state management still happens
+ expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]);
+
+ // Verify email is NOT stored in localStorage on error
+ expect(window.localStorage.getItem("emailForSignIn")).toBeNull();
+ });
+
+ it("should use current window location for action code settings", async () => {
+ const mockUI = createMockUI();
+ const email = "test@example.com";
+
+ Object.defineProperty(window, 'location', {
+ value: { href: 'https://myapp.com/auth' },
+ writable: true
+ });
+
+ vi.mocked(_sendSignInLinkToEmail).mockResolvedValue(undefined);
+
+ await sendSignInLinkToEmail(mockUI, email);
+
+ const expectedActionCodeSettings = {
+ url: 'https://myapp.com/auth',
+ handleCodeInApp: true,
+ };
+ expect(_sendSignInLinkToEmail).toHaveBeenCalledWith(mockUI.auth, email, expectedActionCodeSettings);
+ });
+
+ it("should overwrite existing email in localStorage", async () => {
+ const mockUI = createMockUI();
+ const email = "test@example.com";
+ const existingEmail = "old@example.com";
+
+ window.localStorage.setItem("emailForSignIn", existingEmail);
+
+ vi.mocked(_sendSignInLinkToEmail).mockResolvedValue(undefined);
+
+ await sendSignInLinkToEmail(mockUI, email);
+
+ expect(window.localStorage.getItem("emailForSignIn")).toBe(email);
+ });
+ });
+
+ describe("signInWithEmailLink", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("should update state and call signInWithCredential with no behavior", async () => {
+ const mockUI = createMockUI();
+ const email = "test@example.com";
+ const link = "https://example.com/auth?oobCode=abc123";
+
+ const credential = EmailAuthProvider.credentialWithLink(email, link);
+ vi.mocked(hasBehavior).mockReturnValue(false);
+ vi.mocked(EmailAuthProvider.credentialWithLink).mockReturnValue(credential);
+ vi.mocked(signInWithCredential).mockResolvedValue({ providerId: "emailLink" } as UserCredential);
+
+ const result = await signInWithEmailLink(mockUI, email, link);
+
+ expect(hasBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential");
+
+ // Calls pending pre-signInWithCredential call, then idle after.
+ expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]);
+
+ expect(signInWithCredential).toHaveBeenCalledWith(mockUI.auth, credential);
+ expect(signInWithCredential).toHaveBeenCalledTimes(1);
+
+ // Assert that the result is a valid UserCredential.
+ expect(result.providerId).toBe("emailLink");
+ });
+
+ it('should call the autoUpgradeAnonymousCredential behavior if enabled and return a value', async () => {
+ const mockUI = createMockUI();
+ const email = "test@example.com";
+ const link = "https://example.com/auth?oobCode=abc123";
+
+ const credential = EmailAuthProvider.credentialWithLink(email, link);
+ vi.mocked(hasBehavior).mockReturnValue(true);
+ vi.mocked(EmailAuthProvider.credentialWithLink).mockReturnValue(credential);
+ const mockBehavior = vi.fn().mockResolvedValue({ providerId: "emailLink" } as UserCredential);
+ vi.mocked(getBehavior).mockReturnValue(mockBehavior);
+
+ const result = await signInWithEmailLink(mockUI, email, link);
+
+ expect(hasBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential");
+ expect(getBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential");
+
+ expect(mockBehavior).toHaveBeenCalledWith(mockUI, credential);
+ expect(result.providerId).toBe("emailLink");
+
+ // Only the `finally` block is called here.
+ expect(vi.mocked(mockUI.setState).mock.calls).toEqual([['idle']]);
+ });
+
+ it('should call the autoUpgradeAnonymousCredential behavior if enabled and handle no result from the behavior', async () => {
+ const mockUI = createMockUI();
+ const email = "test@example.com";
+ const link = "https://example.com/auth?oobCode=abc123";
+
+ const credential = EmailAuthProvider.credentialWithLink(email, link);
+ vi.mocked(hasBehavior).mockReturnValue(true);
+ vi.mocked(EmailAuthProvider.credentialWithLink).mockReturnValue(credential);
+ const mockBehavior = vi.fn().mockResolvedValue(undefined);
+ vi.mocked(getBehavior).mockReturnValue(mockBehavior);
+
+ await signInWithEmailLink(mockUI, email, link);
+
+ expect(hasBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential");
+ expect(getBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousCredential");
+
+ expect(mockBehavior).toHaveBeenCalledWith(mockUI, credential);
+
+ expect(signInWithCredential).toHaveBeenCalledWith(mockUI.auth, credential);
+ expect(signInWithCredential).toHaveBeenCalledTimes(1);
+
+ // Calls pending pre-signInWithCredential call, then idle after.
+ expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]);
+ });
+
+ it("should call handleFirebaseError if an error is thrown", async () => {
+ const mockUI = createMockUI();
+ const email = "test@example.com";
+ const link = "https://example.com/auth?oobCode=abc123";
+
+ vi.mocked(hasBehavior).mockReturnValue(false);
+
+ const error = new FirebaseError('auth/invalid-action-code', 'Invalid action code');
+
+ vi.mocked(signInWithCredential).mockRejectedValue(error);
+
+ await signInWithEmailLink(mockUI, email, link);
+
+ expect(handleFirebaseError).toHaveBeenCalledWith(mockUI, error);
+ expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]);
+ });
+ });
+
+ describe("signInAnonymously", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("should update state and call signInAnonymously successfully", async () => {
+ const mockUI = createMockUI();
+ const mockUserCredential = {
+ user: { uid: "anonymous-uid", isAnonymous: true },
+ providerId: "anonymous",
+ operationType: "signIn",
+ } as UserCredential;
+
+ vi.mocked(_signInAnonymously).mockResolvedValue(mockUserCredential);
+
+ const result = await signInAnonymously(mockUI);
+
+ // Verify state management
+ expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]);
+
+ // Verify the Firebase function was called with correct parameters
+ expect(_signInAnonymously).toHaveBeenCalledWith(mockUI.auth);
+ expect(_signInAnonymously).toHaveBeenCalledTimes(1);
+
+ // Verify the result
+ expect(result).toEqual(mockUserCredential);
+ });
+
+ it("should call handleFirebaseError if an error is thrown", async () => {
+ const mockUI = createMockUI();
+ const error = new FirebaseError('auth/operation-not-allowed', 'Anonymous sign-in is not enabled');
+
+ vi.mocked(_signInAnonymously).mockRejectedValue(error);
+
+ await signInAnonymously(mockUI);
+
+ // Verify error handling
+ expect(handleFirebaseError).toHaveBeenCalledWith(mockUI, error);
+
+ // Verify state management still happens
+ expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]);
+ });
+ });
+
+ describe("signInWithProvider", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("should call signInWithRedirect with no behavior", async () => {
+ const mockUI = createMockUI();
+ const provider = { providerId: "google.com" } as AuthProvider;
+
+ vi.mocked(hasBehavior).mockReturnValue(false);
+ vi.mocked(signInWithRedirect).mockResolvedValue(undefined as never);
+
+ await signInWithProvider(mockUI, provider);
+
+ expect(hasBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousProvider");
+
+ expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]);
+
+ expect(signInWithRedirect).toHaveBeenCalledWith(mockUI.auth, provider);
+ expect(signInWithRedirect).toHaveBeenCalledTimes(1);
+ });
+
+ it("should call autoUpgradeAnonymousProvider behavior if enabled", async () => {
+ const mockUI = createMockUI();
+ const provider = { providerId: "google.com" } as AuthProvider;
+
+ vi.mocked(hasBehavior).mockReturnValue(true);
+ const mockBehavior = vi.fn().mockResolvedValue(undefined);
+ vi.mocked(getBehavior).mockReturnValue(mockBehavior);
+ vi.mocked(signInWithRedirect).mockResolvedValue(undefined as never);
+
+ await signInWithProvider(mockUI, provider);
+
+ expect(hasBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousProvider");
+ expect(getBehavior).toHaveBeenCalledWith(mockUI, "autoUpgradeAnonymousProvider");
+ expect(mockBehavior).toHaveBeenCalledWith(mockUI, provider);
+
+ expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]);
+
+ expect(signInWithRedirect).toHaveBeenCalledWith(mockUI.auth, provider);
+ });
+
+ it("should call handleFirebaseError if an error is thrown", async () => {
+ const mockUI = createMockUI();
+ const provider = { providerId: "google.com" } as AuthProvider;
+ const error = new FirebaseError('auth/operation-not-allowed', 'Google sign-in is not enabled');
+
+ vi.mocked(hasBehavior).mockReturnValue(false);
+ vi.mocked(signInWithRedirect).mockRejectedValue(error);
+
+ await signInWithProvider(mockUI, provider);
+
+ expect(handleFirebaseError).toHaveBeenCalledWith(mockUI, error);
+
+ expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]);
+ });
+
+ it("should handle behavior errors", async () => {
+ const mockUI = createMockUI();
+ const provider = { providerId: "google.com" } as AuthProvider;
+ const error = new Error("Behavior error");
+
+ vi.mocked(hasBehavior).mockReturnValue(true);
+ const mockBehavior = vi.fn().mockRejectedValue(error);
+ vi.mocked(getBehavior).mockReturnValue(mockBehavior);
+
+ await signInWithProvider(mockUI, provider);
+
+ expect(handleFirebaseError).toHaveBeenCalledWith(mockUI, error);
+
+ expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["idle"]]);
+
+ expect(signInWithRedirect).not.toHaveBeenCalled();
+ });
+
+ it("should handle errors from signInWithRedirect", async () => {
+ const mockUI = createMockUI();
+ const provider = { providerId: "google.com" } as AuthProvider;
+ const error = new FirebaseError("auth/operation-not-allowed", "Operation not allowed");
+
+ vi.mocked(hasBehavior).mockReturnValue(false);
+ vi.mocked(signInWithRedirect).mockRejectedValue(error);
+
+ await signInWithProvider(mockUI, provider);
+
+ expect(handleFirebaseError).toHaveBeenCalledWith(mockUI, error);
+
+ expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]);
+ });
+ });
+
+// TODO(ehesp): Test completeEmailLinkSignIn - it depends on an internal function
+// which you can't mock: https://vitest.dev/guide/mocking.html#mocking-pitfalls
\ No newline at end of file
diff --git a/packages/core/src/auth.ts b/packages/core/src/auth.ts
index c0a93bd4..a7e2dfe0 100644
--- a/packages/core/src/auth.ts
+++ b/packages/core/src/auth.ts
@@ -63,7 +63,7 @@ export async function signInWithEmailAndPassword(
if (hasBehavior(ui, "autoUpgradeAnonymousCredential")) {
const result = await getBehavior(ui, "autoUpgradeAnonymousCredential")(ui, credential);
-
+
if (result) {
return handlePendingCredential(ui, result);
}
@@ -168,6 +168,7 @@ export async function sendSignInLinkToEmail(ui: FirebaseUIConfiguration, email:
ui.setState("pending");
await _sendSignInLinkToEmail(ui.auth, email, actionCodeSettings);
+ // TODO: Should this be a behavior ("storageStrategy")?
window.localStorage.setItem("emailForSignIn", email);
} catch (error) {
handleFirebaseError(ui, error);
@@ -248,7 +249,7 @@ export async function completeEmailLinkSignIn(
ui.setState("pending");
const result = await signInWithEmailLink(ui, email, currentUrl);
- ui.setState("idle");
+ ui.setState("idle"); // TODO(ehesp): Do we need this here?
return handlePendingCredential(ui, result);
} catch (error) {
handleFirebaseError(ui, error);
diff --git a/packages/core/src/behaviors.test.ts b/packages/core/src/behaviors.test.ts
new file mode 100644
index 00000000..fad81aa9
--- /dev/null
+++ b/packages/core/src/behaviors.test.ts
@@ -0,0 +1,220 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { createMockUI } from "~/tests/utils";
+import { autoAnonymousLogin, autoUpgradeAnonymousUsers, getBehavior, hasBehavior } from "./behaviors";
+import { Auth, signInAnonymously, User, UserCredential, linkWithCredential, linkWithRedirect, AuthCredential, AuthProvider } from "firebase/auth";
+
+vi.mock("firebase/auth", () => ({
+ signInAnonymously: vi.fn(),
+ linkWithCredential: vi.fn(),
+ linkWithRedirect: vi.fn(),
+}));
+
+describe("hasBehavior", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("should return true if the behavior is enabled, but not call it", () => {
+ const mockBehavior = vi.fn();
+ const ui = createMockUI({
+ behaviors: {
+ autoAnonymousLogin: mockBehavior,
+ },
+ });
+
+ expect(hasBehavior(ui, "autoAnonymousLogin")).toBe(true);
+ expect(mockBehavior).not.toHaveBeenCalled();
+ });
+
+ it("should return false if the behavior is not enabled", () => {
+ const ui = createMockUI();
+ expect(hasBehavior(ui, "autoAnonymousLogin")).toBe(false);
+ });
+});
+
+describe("getBehavior", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("should throw if the behavior is not enabled", () => {
+ const ui = createMockUI();
+ expect(() => getBehavior(ui, "autoAnonymousLogin")).toThrow();
+ });
+
+ it("should call the behavior if it is enabled", () => {
+ const mockBehavior = vi.fn();
+ const ui = createMockUI({
+ behaviors: {
+ autoAnonymousLogin: mockBehavior,
+ },
+ });
+
+ expect(hasBehavior(ui, "autoAnonymousLogin")).toBe(true);
+ expect(getBehavior(ui, "autoAnonymousLogin")).toBe(mockBehavior);
+ });
+});
+
+describe("autoAnonymousLogin", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it('should sign the user in anonymously if they are not signed in', async () => {
+ const mockAuthStateReady = vi.fn().mockResolvedValue(undefined);
+ const mockUI = createMockUI({
+ auth: {
+ currentUser: null,
+ authStateReady: mockAuthStateReady,
+ } as unknown as Auth,
+ });
+
+ vi.mocked(signInAnonymously).mockResolvedValue({} as UserCredential);
+
+ await autoAnonymousLogin().autoAnonymousLogin(mockUI);
+
+ expect(mockAuthStateReady).toHaveBeenCalled();
+ expect(signInAnonymously).toHaveBeenCalledWith(mockUI.auth);
+
+ expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["loading"], ["idle"]]);
+ });
+
+ it('should not attempt to sign in anonymously if the user is already signed in', async () => {
+ const mockAuthStateReady = vi.fn().mockResolvedValue(undefined);
+ const mockUI = createMockUI({
+ auth: {
+ currentUser: { uid: "test-user" } as User,
+ authStateReady: mockAuthStateReady,
+ } as unknown as Auth,
+ });
+
+ vi.mocked(signInAnonymously).mockResolvedValue({} as UserCredential);
+
+ await autoAnonymousLogin().autoAnonymousLogin(mockUI);
+
+ expect(mockAuthStateReady).toHaveBeenCalled();
+ expect(signInAnonymously).not.toHaveBeenCalled();
+
+ expect(vi.mocked(mockUI.setState).mock.calls).toEqual([ ["idle"]]);
+ });
+
+ it("should return noop behavior in SSR mode", async () => {
+ // Mock window as undefined to simulate SSR
+ const originalWindow = global.window;
+ // @ts-ignore
+ delete global.window;
+
+ const behavior = autoAnonymousLogin();
+ const mockUI = createMockUI();
+
+ const result = await behavior.autoAnonymousLogin(mockUI);
+
+ expect(result).toEqual({ uid: "server-placeholder" });
+ expect(signInAnonymously).not.toHaveBeenCalled();
+
+ // Restore window
+ global.window = originalWindow;
+ });
+});
+
+describe("autoUpgradeAnonymousUsers", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ describe("autoUpgradeAnonymousCredential", () => {
+ it("should upgrade anonymous user with credential", async () => {
+ const mockCredential = { providerId: "password" } as AuthCredential;
+ const mockUserCredential = { user: { uid: "test-user" } } as UserCredential;
+ const mockAnonymousUser = { uid: "anonymous-user", isAnonymous: true } as User;
+
+ const mockUI = createMockUI({
+ auth: {
+ currentUser: mockAnonymousUser,
+ } as unknown as Auth,
+ });
+
+ vi.mocked(linkWithCredential).mockResolvedValue(mockUserCredential);
+
+ const behavior = autoUpgradeAnonymousUsers();
+ const result = await behavior.autoUpgradeAnonymousCredential(mockUI, mockCredential);
+
+ expect(linkWithCredential).toHaveBeenCalledWith(mockAnonymousUser, mockCredential);
+ expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"], ["idle"]]);
+ expect(result).toBe(mockUserCredential);
+ });
+
+ it("should return undefined if user is not anonymous", async () => {
+ const mockCredential = { providerId: "password" } as AuthCredential;
+ const mockRegularUser = { uid: "regular-user", isAnonymous: false } as User;
+
+ const mockUI = createMockUI({
+ auth: {
+ currentUser: mockRegularUser,
+ } as unknown as Auth,
+ });
+
+ const behavior = autoUpgradeAnonymousUsers();
+ const result = await behavior.autoUpgradeAnonymousCredential(mockUI, mockCredential);
+
+ expect(linkWithCredential).not.toHaveBeenCalled();
+ expect(vi.mocked(mockUI.setState).mock.calls).toEqual([]);
+ expect(result).toBeUndefined();
+ });
+
+ it("should return undefined if no current user", async () => {
+ const mockCredential = { providerId: "password" } as AuthCredential;
+
+ const mockUI = createMockUI({
+ auth: {
+ currentUser: null,
+ } as unknown as Auth,
+ });
+
+ const behavior = autoUpgradeAnonymousUsers();
+ const result = await behavior.autoUpgradeAnonymousCredential(mockUI, mockCredential);
+
+ expect(linkWithCredential).not.toHaveBeenCalled();
+ expect(vi.mocked(mockUI.setState).mock.calls).toEqual([]);
+ expect(result).toBeUndefined();
+ });
+ });
+
+ describe("autoUpgradeAnonymousProvider", () => {
+ it("should upgrade anonymous user with provider", async () => {
+ const mockProvider = { providerId: "google.com" } as AuthProvider;
+ const mockAnonymousUser = { uid: "anonymous-user", isAnonymous: true } as User;
+
+ const mockUI = createMockUI({
+ auth: {
+ currentUser: mockAnonymousUser,
+ } as unknown as Auth,
+ });
+
+ vi.mocked(linkWithRedirect).mockResolvedValue(undefined as never);
+
+ const behavior = autoUpgradeAnonymousUsers();
+ await behavior.autoUpgradeAnonymousProvider(mockUI, mockProvider);
+
+ expect(linkWithRedirect).toHaveBeenCalledWith(mockAnonymousUser, mockProvider);
+ expect(vi.mocked(mockUI.setState).mock.calls).toEqual([["pending"]]);
+ });
+
+ it("should return early if user is not anonymous", async () => {
+ const mockProvider = { providerId: "google.com" } as AuthProvider;
+ const mockRegularUser = { uid: "regular-user", isAnonymous: false } as User;
+
+ const mockUI = createMockUI({
+ auth: {
+ currentUser: mockRegularUser,
+ } as unknown as Auth,
+ });
+
+ const behavior = autoUpgradeAnonymousUsers();
+ await behavior.autoUpgradeAnonymousProvider(mockUI, mockProvider);
+
+ expect(linkWithRedirect).not.toHaveBeenCalled();
+ expect(vi.mocked(mockUI.setState).mock.calls).toEqual([]);
+ });
+ });
+});
diff --git a/packages/core/src/config.test.ts b/packages/core/src/config.test.ts
new file mode 100644
index 00000000..8391f575
--- /dev/null
+++ b/packages/core/src/config.test.ts
@@ -0,0 +1,70 @@
+import { FirebaseApp } from "firebase/app";
+import { Auth } from "firebase/auth";
+import { describe, it, expect } from "vitest";
+import { initializeUI } from "./config";
+import { enUs, registerLocale } from "@firebase-ui/translations";
+import { autoUpgradeAnonymousUsers } from "./behaviors";
+
+describe('initializeUI', () => {
+ it('should return a valid deep store with default values', () => {
+ const config = {
+ app: {} as FirebaseApp,
+ auth: {} as Auth,
+ };
+
+ const ui = initializeUI(config);
+ expect(ui).toBeDefined();
+ expect(ui.get()).toBeDefined();
+ expect(ui.get().app).toBe(config.app);
+ expect(ui.get().auth).toBe(config.auth);
+ expect(ui.get().behaviors).toEqual({});
+ expect(ui.get().state).toEqual("idle");
+ expect(ui.get().locale).toEqual(enUs);
+ });
+
+ it('should merge behaviors', () => {
+ const config = {
+ app: {} as FirebaseApp,
+ auth: {} as Auth,
+ behaviors: [autoUpgradeAnonymousUsers()],
+ };
+
+ const ui = initializeUI(config);
+ expect(ui).toBeDefined();
+ expect(ui.get()).toBeDefined();
+ expect(ui.get().behaviors).toHaveProperty("autoUpgradeAnonymousCredential");
+ expect(ui.get().behaviors).toHaveProperty("autoUpgradeAnonymousProvider");
+ });
+
+ it('should set state and update state when called', () => {
+ const config = {
+ app: {} as FirebaseApp,
+ auth: {} as Auth,
+ };
+
+ const ui = initializeUI(config);
+ expect(ui.get().state).toEqual("idle");
+ ui.get().setState("loading");
+ expect(ui.get().state).toEqual("loading");
+ ui.get().setState("idle");
+ expect(ui.get().state).toEqual("idle");
+ });
+
+ it('should set state and update locale when called', () => {
+ const testLocale1 = registerLocale('test1', {});
+ const testLocale2 = registerLocale('test2', {});
+
+ const config = {
+ app: {} as FirebaseApp,
+ auth: {} as Auth,
+ };
+
+ const ui = initializeUI(config);
+ expect(ui.get().locale.locale).toEqual('en-US');
+ ui.get().setLocale(testLocale1);
+ expect(ui.get().locale.locale).toEqual('test1');
+ ui.get().setLocale(testLocale2);
+ expect(ui.get().locale.locale).toEqual('test2');
+ });
+});
+
diff --git a/packages/core/src/country-data.test.ts b/packages/core/src/country-data.test.ts
new file mode 100644
index 00000000..6890b5ec
--- /dev/null
+++ b/packages/core/src/country-data.test.ts
@@ -0,0 +1,180 @@
+import { describe, it, expect } from "vitest";
+import {
+ countryData,
+ getCountryByDialCode,
+ getCountryByCode,
+ formatPhoneNumberWithCountry
+} from "./country-data";
+
+describe("CountryData", () => {
+ describe("CountryData interface", () => {
+ it("should have correct structure for all countries", () => {
+ countryData.forEach((country) => {
+ expect(country).toHaveProperty("name");
+ expect(country).toHaveProperty("dialCode");
+ expect(country).toHaveProperty("code");
+ expect(country).toHaveProperty("emoji");
+
+ expect(typeof country.name).toBe("string");
+ expect(typeof country.dialCode).toBe("string");
+ expect(typeof country.code).toBe("string");
+ expect(typeof country.emoji).toBe("string");
+
+ expect(country.name.length).toBeGreaterThan(0);
+ expect(country.dialCode).toMatch(/^\+\d+$/);
+ expect(country.code).toMatch(/^[A-Z]{2}$/);
+ expect(country.emoji.length).toBeGreaterThan(0);
+ });
+ });
+ });
+
+ describe("countryData array", () => {
+ it("should have valid dial codes", () => {
+ countryData.forEach((country) => {
+ expect(country.dialCode).toMatch(/^\+\d{1,4}$/);
+ expect(country.dialCode.length).toBeGreaterThanOrEqual(2); // +1
+ expect(country.dialCode.length).toBeLessThanOrEqual(5); // +1234
+ });
+ });
+
+ it("should have valid country codes (ISO 3166-1 alpha-2)", () => {
+ countryData.forEach((country) => {
+ expect(country.code).toMatch(/^[A-Z]{2}$/);
+ });
+ });
+
+ it("should have valid emojis", () => {
+ countryData.forEach((country) => {
+ // Emojis should be flag emojis (typically 2 characters in UTF-16)
+ expect(country.emoji.length).toBeGreaterThan(0);
+ // Most flag emojis are 4 bytes in UTF-8, but some might be different
+ expect(country.emoji).toMatch(/[\u{1F1E6}-\u{1F1FF}]{2}/u);
+ });
+ });
+ });
+
+ describe("getCountryByDialCode", () => {
+ it("should return correct country for valid dial code", () => {
+ const usCountry = getCountryByDialCode("+1");
+ expect(usCountry).toBeDefined();
+ expect(usCountry?.code).toBe("US");
+ expect(usCountry?.name).toBe("United States");
+
+ const ukCountry = getCountryByDialCode("+44");
+ expect(ukCountry).toBeDefined();
+ expect(ukCountry?.code).toBe("GB");
+ expect(ukCountry?.name).toBe("United Kingdom");
+
+ const japanCountry = getCountryByDialCode("+81");
+ expect(japanCountry).toBeDefined();
+ expect(japanCountry?.code).toBe("JP");
+ expect(japanCountry?.name).toBe("Japan");
+ });
+
+ it("should return undefined for invalid dial code", () => {
+ expect(getCountryByDialCode("+999")).toBeUndefined();
+ expect(getCountryByDialCode("invalid")).toBeUndefined();
+ expect(getCountryByDialCode("")).toBeUndefined();
+ });
+
+ it("should handle dial codes with multiple countries", () => {
+ const countries = countryData.filter(country => country.dialCode === "+1");
+ expect(countries.length).toBeGreaterThan(1);
+
+ // Should return the first match (US)
+ const result = getCountryByDialCode("+1");
+ expect(result?.code).toBe("US");
+ });
+ });
+
+ describe("getCountryByCode", () => {
+ it("should return correct country for valid country code", () => {
+ const usCountry = getCountryByCode("US");
+ expect(usCountry).toBeDefined();
+ expect(usCountry?.code).toBe("US");
+ expect(usCountry?.name).toBe("United States");
+ expect(usCountry?.dialCode).toBe("+1");
+
+ const ukCountry = getCountryByCode("GB");
+ expect(ukCountry).toBeDefined();
+ expect(ukCountry?.code).toBe("GB");
+ expect(ukCountry?.name).toBe("United Kingdom");
+ expect(ukCountry?.dialCode).toBe("+44");
+ });
+
+ it("should handle case insensitive country codes", () => {
+ expect(getCountryByCode("us")).toBeDefined();
+ expect(getCountryByCode("Us")).toBeDefined();
+ expect(getCountryByCode("uS")).toBeDefined();
+ expect(getCountryByCode("US")).toBeDefined();
+
+ const result = getCountryByCode("us");
+ expect(result?.code).toBe("US");
+ });
+
+ it("should return undefined for invalid country code", () => {
+ expect(getCountryByCode("XX")).toBeUndefined();
+ expect(getCountryByCode("INVALID")).toBeUndefined();
+ expect(getCountryByCode("")).toBeUndefined();
+ expect(getCountryByCode("U")).toBeUndefined();
+ expect(getCountryByCode("USA")).toBeUndefined();
+ });
+
+ it("should handle special characters in country codes", () => {
+ expect(getCountryByCode("XK")).toBeDefined(); // Kosovo
+ });
+ });
+
+ describe("formatPhoneNumberWithCountry", () => {
+ it("should format phone number with country dial code", () => {
+ expect(formatPhoneNumberWithCountry("1234567890", "+1")).toBe("+11234567890");
+ expect(formatPhoneNumberWithCountry("1234567890", "+44")).toBe("+441234567890");
+ expect(formatPhoneNumberWithCountry("1234567890", "+81")).toBe("+811234567890");
+ });
+
+ it("should handle phone numbers with spaces", () => {
+ expect(formatPhoneNumberWithCountry("123 456 7890", "+1")).toBe("+1123 456 7890");
+ expect(formatPhoneNumberWithCountry(" 1234567890 ", "+1")).toBe("+11234567890");
+ });
+
+ it("should handle empty phone numbers", () => {
+ expect(formatPhoneNumberWithCountry("", "+1")).toBe("+1");
+ expect(formatPhoneNumberWithCountry(" ", "+1")).toBe("+1");
+ });
+
+ it("should handle phone numbers with dashes and parentheses", () => {
+ expect(formatPhoneNumberWithCountry("(123) 456-7890", "+1")).toBe("+1(123) 456-7890");
+ expect(formatPhoneNumberWithCountry("123-456-7890", "+1")).toBe("+1123-456-7890");
+ });
+
+ it("should handle international numbers with existing dial codes", () => {
+ expect(formatPhoneNumberWithCountry("+44 20 7946 0958", "+1")).toBe("+120 7946 0958");
+ expect(formatPhoneNumberWithCountry("+81 3 1234 5678", "+44")).toBe("+443 1234 5678");
+ });
+
+ it("should handle edge cases", () => {
+ expect(formatPhoneNumberWithCountry("1234567890", "+1234")).toBe("+12341234567890");
+ expect(formatPhoneNumberWithCountry("1234567890", "+7")).toBe("+71234567890");
+ });
+ });
+
+ describe("Edge cases and error handling", () => {
+ it("should handle very long phone numbers", () => {
+ const longNumber = "12345678901234567890";
+ expect(formatPhoneNumberWithCountry(longNumber, "+1")).toBe("+112345678901234567890");
+ });
+
+ it("should handle countries with multiple dial codes", () => {
+ const kosovoCountries = countryData.filter(country => country.code === "XK");
+ expect(kosovoCountries.length).toBeGreaterThan(1);
+
+ const result1 = getCountryByDialCode("+377");
+ const result2 = getCountryByDialCode("+381");
+ const result3 = getCountryByDialCode("+386");
+
+ expect(result1?.code).toBe("XK");
+ expect(result2?.code).toBe("XK");
+ expect(result3?.code).toBe("XK");
+ });
+ });
+});
diff --git a/packages/core/src/errors.test.ts b/packages/core/src/errors.test.ts
new file mode 100644
index 00000000..0cf66527
--- /dev/null
+++ b/packages/core/src/errors.test.ts
@@ -0,0 +1,243 @@
+import { describe, it, expect, vi, beforeEach } from "vitest";
+import { FirebaseError } from "firebase/app";
+import { AuthCredential } from "firebase/auth";
+import { FirebaseUIError, handleFirebaseError } from "./errors";
+import { createMockUI } from "~/tests/utils";
+import { ERROR_CODE_MAP } from "@firebase-ui/translations";
+
+// Mock the translations module
+vi.mock("./translations", () => ({
+ getTranslation: vi.fn(),
+}));
+
+import { getTranslation } from "./translations";
+
+let mockSessionStorage: { [key: string]: string };
+
+beforeEach(() => {
+ vi.clearAllMocks();
+
+ // Mock sessionStorage
+ mockSessionStorage = {};
+ Object.defineProperty(window, 'sessionStorage', {
+ value: {
+ setItem: vi.fn((key: string, value: string) => {
+ mockSessionStorage[key] = value;
+ }),
+ getItem: vi.fn((key: string) => mockSessionStorage[key] || null),
+ removeItem: vi.fn((key: string) => {
+ delete mockSessionStorage[key];
+ }),
+ clear: vi.fn(() => {
+ Object.keys(mockSessionStorage).forEach(key => delete mockSessionStorage[key]);
+ }),
+ },
+ writable: true,
+ });
+});
+
+describe("FirebaseUIError", () => {
+ beforeEach(() => {
+ vi.clearAllMocks();
+ });
+
+ it("should create a FirebaseUIError with translated message", () => {
+ const mockUI = createMockUI();
+ const mockFirebaseError = new FirebaseError("auth/user-not-found", "User not found");
+ const expectedTranslation = "User not found (translated)";
+
+ vi.mocked(getTranslation).mockReturnValue(expectedTranslation);
+
+ const error = new FirebaseUIError(mockUI, mockFirebaseError);
+
+ expect(error).toBeInstanceOf(FirebaseError);
+ expect(error.code).toBe("auth/user-not-found");
+ expect(error.message).toBe(expectedTranslation);
+ expect(getTranslation).toHaveBeenCalledWith(
+ mockUI,
+ "errors",
+ ERROR_CODE_MAP["auth/user-not-found"]
+ );
+ });
+
+ it("should handle unknown error codes gracefully", () => {
+ const mockUI = createMockUI();
+ const mockFirebaseError = new FirebaseError("auth/unknown-error", "Unknown error");
+ const expectedTranslation = "Unknown error (translated)";
+
+ vi.mocked(getTranslation).mockReturnValue(expectedTranslation);
+
+ const error = new FirebaseUIError(mockUI, mockFirebaseError);
+
+ expect(error.code).toBe("auth/unknown-error");
+ expect(error.message).toBe(expectedTranslation);
+ expect(getTranslation).toHaveBeenCalledWith(
+ mockUI,
+ "errors",
+ ERROR_CODE_MAP["auth/unknown-error" as keyof typeof ERROR_CODE_MAP]
+ );
+ });
+});
+
+describe("handleFirebaseError", () => {
+ it("should throw non-Firebase errors as-is", () => {
+ const mockUI = createMockUI();
+ const nonFirebaseError = new Error("Regular error");
+
+ expect(() => handleFirebaseError(mockUI, nonFirebaseError)).toThrow("Regular error");
+ });
+
+ it("should throw non-Firebase errors with different types", () => {
+ const mockUI = createMockUI();
+ const stringError = "String error";
+ const numberError = 42;
+ const nullError = null;
+ const undefinedError = undefined;
+
+ expect(() => handleFirebaseError(mockUI, stringError)).toThrow("String error");
+ expect(() => handleFirebaseError(mockUI, numberError)).toThrow();
+ expect(() => handleFirebaseError(mockUI, nullError)).toThrow();
+ expect(() => handleFirebaseError(mockUI, undefinedError)).toThrow();
+ });
+
+ it("should throw FirebaseUIError for Firebase errors", () => {
+ const mockUI = createMockUI();
+ const mockFirebaseError = new FirebaseError("auth/user-not-found", "User not found");
+ const expectedTranslation = "User not found (translated)";
+
+ vi.mocked(getTranslation).mockReturnValue(expectedTranslation);
+
+ expect(() => handleFirebaseError(mockUI, mockFirebaseError)).toThrow(FirebaseUIError);
+
+ try {
+ handleFirebaseError(mockUI, mockFirebaseError);
+ } catch (error) {
+ // Should be an instance of both FirebaseUIError and FirebaseError
+ expect(error).toBeInstanceOf(FirebaseUIError);
+ expect(error).toBeInstanceOf(FirebaseError);
+ expect((error as FirebaseUIError).code).toBe("auth/user-not-found");
+ expect((error as FirebaseUIError).message).toBe(expectedTranslation);
+ }
+ });
+
+ it("should store credential in sessionStorage for account-exists-with-different-credential", () => {
+ const mockUI = createMockUI();
+ const mockCredential = {
+ providerId: "google.com",
+ toJSON: vi.fn().mockReturnValue({ providerId: "google.com", token: "mock-token" })
+ } as unknown as AuthCredential;
+
+ const mockFirebaseError = {
+ code: "auth/account-exists-with-different-credential",
+ message: "Account exists with different credential",
+ credential: mockCredential
+ } as FirebaseError & { credential: AuthCredential };
+
+ const expectedTranslation = "Account exists with different credential (translated)";
+ vi.mocked(getTranslation).mockReturnValue(expectedTranslation);
+
+ expect(() => handleFirebaseError(mockUI, mockFirebaseError)).toThrow(FirebaseUIError);
+
+ expect(window.sessionStorage.setItem).toHaveBeenCalledWith(
+ "pendingCred",
+ JSON.stringify(mockCredential.toJSON())
+ );
+ expect(mockCredential.toJSON).toHaveBeenCalled();
+ });
+
+ it("should not store credential for other error types", () => {
+ const mockUI = createMockUI();
+ const mockFirebaseError = new FirebaseError("auth/user-not-found", "User not found");
+ const expectedTranslation = "User not found (translated)";
+
+ vi.mocked(getTranslation).mockReturnValue(expectedTranslation);
+
+ expect(() => handleFirebaseError(mockUI, mockFirebaseError)).toThrow(FirebaseUIError);
+
+ expect(window.sessionStorage.setItem).not.toHaveBeenCalled();
+ });
+
+ it("should handle account-exists-with-different-credential without credential", () => {
+ const mockUI = createMockUI();
+ const mockFirebaseError = {
+ code: "auth/account-exists-with-different-credential",
+ message: "Account exists with different credential"
+ } as FirebaseError;
+
+ const expectedTranslation = "Account exists with different credential (translated)";
+ vi.mocked(getTranslation).mockReturnValue(expectedTranslation);
+
+ expect(() => handleFirebaseError(mockUI, mockFirebaseError)).toThrow(FirebaseUIError);
+
+ // Should not try to store credential if it doesn't exist
+ expect(window.sessionStorage.setItem).not.toHaveBeenCalled();
+ });
+});
+
+describe("isFirebaseError utility", () => {
+ it("should identify FirebaseError objects", () => {
+ const firebaseError = new FirebaseError("auth/user-not-found", "User not found");
+
+ // We can't directly test the private function, but we can test it through handleFirebaseError
+ const mockUI = createMockUI();
+ vi.mocked(getTranslation).mockReturnValue("translated message");
+
+ expect(() => handleFirebaseError(mockUI, firebaseError)).toThrow(FirebaseUIError);
+ });
+
+ it("should reject non-FirebaseError objects", () => {
+ const mockUI = createMockUI();
+ const nonFirebaseError = { code: "test", message: "test" }; // Missing proper structure
+
+ expect(() => handleFirebaseError(mockUI, nonFirebaseError)).toThrow();
+ });
+
+ it("should reject objects without code and message", () => {
+ const mockUI = createMockUI();
+ const invalidObject = { someProperty: "value" };
+
+ expect(() => handleFirebaseError(mockUI, invalidObject)).toThrow();
+ });
+});
+
+describe("errorContainsCredential utility", () => {
+ it("should identify FirebaseError with credential", () => {
+ const mockUI = createMockUI();
+ const mockCredential = {
+ providerId: "google.com",
+ toJSON: vi.fn().mockReturnValue({ providerId: "google.com" })
+ } as unknown as AuthCredential;
+
+ const firebaseErrorWithCredential = {
+ code: "auth/account-exists-with-different-credential",
+ message: "Account exists with different credential",
+ credential: mockCredential
+ } as FirebaseError & { credential: AuthCredential };
+
+ vi.mocked(getTranslation).mockReturnValue("translated message");
+
+ expect(() => handleFirebaseError(mockUI, firebaseErrorWithCredential)).toThrowError(FirebaseUIError);
+
+ // Should have stored the credential
+ expect(window.sessionStorage.setItem).toHaveBeenCalledWith(
+ "pendingCred",
+ JSON.stringify(mockCredential.toJSON())
+ );
+ });
+
+ it("should handle FirebaseError without credential", () => {
+ const mockUI = createMockUI();
+ const firebaseErrorWithoutCredential = {
+ code: "auth/account-exists-with-different-credential",
+ message: "Account exists with different credential"
+ } as FirebaseError;
+
+ vi.mocked(getTranslation).mockReturnValue("translated message");
+
+ expect(() => handleFirebaseError(mockUI, firebaseErrorWithoutCredential)).toThrowError(FirebaseUIError);
+
+ // Should not have stored any credential
+ expect(window.sessionStorage.setItem).not.toHaveBeenCalled();
+ });
+});
+
diff --git a/packages/core/src/errors.ts b/packages/core/src/errors.ts
index 8687234b..c538a0c4 100644
--- a/packages/core/src/errors.ts
+++ b/packages/core/src/errors.ts
@@ -15,51 +15,45 @@
*/
import { ERROR_CODE_MAP, ErrorCode } from "@firebase-ui/translations";
-import { getTranslation } from "./translations";
+import { FirebaseError } from "firebase/app";
+import { AuthCredential } from "firebase/auth";
import { FirebaseUIConfiguration } from "./config";
-export class FirebaseUIError extends Error {
- code: string;
-
- constructor(error: any, ui: FirebaseUIConfiguration) {
- const errorCode: ErrorCode = error?.customData?.message?.match?.(/\(([^)]+)\)/)?.at(1) || error?.code || "unknown";
- const translationKey = ERROR_CODE_MAP[errorCode] || "unknownError";
- const message = getTranslation(ui, "errors", translationKey);
+import { getTranslation } from "./translations";
+export class FirebaseUIError extends FirebaseError {
+ constructor(ui: FirebaseUIConfiguration, error: FirebaseError) {
+ const message = getTranslation(ui, "errors", ERROR_CODE_MAP[error.code as ErrorCode]);
+ super(error.code, message);
- super(message);
- this.name = "FirebaseUIError";
- this.code = errorCode;
+ // Ensures that `instanceof FirebaseUIError` works, alongside `instanceof FirebaseError`
+ Object.setPrototypeOf(this, FirebaseUIError.prototype);
}
}
export function handleFirebaseError(
ui: FirebaseUIConfiguration,
- error: any,
- opts?: {
- enableHandleExistingCredential?: boolean;
- }
+ error: unknown,
): never {
- // TODO(ehesp): Type error as unknown, check instance of FirebaseError
- if (error?.code === "auth/account-exists-with-different-credential") {
- if (opts?.enableHandleExistingCredential && error.credential) {
- window.sessionStorage.setItem("pendingCred", JSON.stringify(error.credential));
- } else {
- window.sessionStorage.removeItem("pendingCred");
- }
-
- throw new FirebaseUIError(
- {
- code: "auth/account-exists-with-different-credential",
- customData: {
- email: error.customData?.email,
- },
- },
- ui,
- );
+ // If it's not a Firebase error, then we just throw it and preserve the original error.
+ if (!isFirebaseError(error)) {
+ throw error;
}
- // TODO: Debug why instanceof FirebaseError is not working
- if (error?.name === "FirebaseError") {
- throw new FirebaseUIError(error, ui);
+ // TODO(ehesp): Type error as unknown, check instance of FirebaseError
+ // TODO(ehesp): Support via behavior
+ if (error.code === "auth/account-exists-with-different-credential" && errorContainsCredential(error)) {
+ window.sessionStorage.setItem("pendingCred", JSON.stringify(error.credential.toJSON()));
}
- throw new FirebaseUIError({ code: "unknown" }, ui);
+
+ throw new FirebaseUIError(ui, error);
+}
+
+// Utility to obtain whether something is a FirebaseError
+function isFirebaseError(error: unknown): error is FirebaseError {
+ // Calling instanceof FirebaseError is not working - not sure why yet.
+ return !!error && typeof error === "object" && "code" in error && "message" in error;
+}
+
+// Utility to obtain whether something is a FirebaseError that contains a credential - doesn't seemed to be typed?
+function errorContainsCredential(error: FirebaseError): error is FirebaseError & { credential: AuthCredential } {
+ return 'credential' in error;
}
diff --git a/packages/core/src/schemas.test.ts b/packages/core/src/schemas.test.ts
new file mode 100644
index 00000000..f3991408
--- /dev/null
+++ b/packages/core/src/schemas.test.ts
@@ -0,0 +1,201 @@
+import { describe, it, expect } from "vitest";
+import { createMockUI } from "~/tests/utils";
+import { createEmailLinkAuthFormSchema, createForgotPasswordAuthFormSchema, createPhoneAuthFormSchema, createSignInAuthFormSchema, createSignUpAuthFormSchema } from "./schemas";
+import { registerLocale } from "@firebase-ui/translations";
+import { RecaptchaVerifier } from "firebase/auth";
+
+describe("createSignInAuthFormSchema", () => {
+ it("should create a sign in auth form schema with valid error messages", () => {
+ const testLocale = registerLocale('test', {
+ errors: {
+ invalidEmail: "createSignInAuthFormSchema + invalidEmail",
+ weakPassword: "createSignInAuthFormSchema + weakPassword",
+ },
+ });
+
+ const mockUI = createMockUI({
+ locale: testLocale,
+ });
+
+ const schema = createSignInAuthFormSchema(mockUI);
+
+ // Cause the schema to fail...
+ // TODO(ehesp): If no value is provided, the schema error is just "Required" - should this also be translated?
+ const result = schema.safeParse({
+ email: '',
+ password: '',
+ });
+
+ expect(result.success).toBe(false);
+ expect(result.error).toBeDefined();
+ expect(result.error?.issues.length).toBe(2);
+
+ expect(result.error?.issues[0]?.message).toBe("createSignInAuthFormSchema + invalidEmail");
+ expect(result.error?.issues[1]?.message).toBe("createSignInAuthFormSchema + weakPassword");
+ });
+});
+
+describe("createSignUpAuthFormSchema", () => {
+ it("should create a sign up auth form schema with valid error messages", () => {
+ const testLocale = registerLocale('test', {
+ errors: {
+ invalidEmail: "createSignUpAuthFormSchema + invalidEmail",
+ weakPassword: "createSignUpAuthFormSchema + weakPassword",
+ },
+ });
+
+ const mockUI = createMockUI({
+ locale: testLocale,
+ });
+
+ const schema = createSignUpAuthFormSchema(mockUI);
+
+ // Cause the schema to fail...
+ // TODO(ehesp): If no value is provided, the schema error is just "Required" - should this also be translated?
+ const result = schema.safeParse({
+ email: '',
+ password: '',
+ });
+
+ expect(result.success).toBe(false);
+ expect(result.error).toBeDefined();
+ expect(result.error?.issues.length).toBe(2);
+
+ expect(result.error?.issues[0]?.message).toBe("createSignUpAuthFormSchema + invalidEmail");
+ expect(result.error?.issues[1]?.message).toBe("createSignUpAuthFormSchema + weakPassword");
+ });
+});
+
+describe("createForgotPasswordAuthFormSchema", () => {
+ it("should create a forgot password form schema with valid error messages", () => {
+ const testLocale = registerLocale('test', {
+ errors: {
+ invalidEmail: "createForgotPasswordAuthFormSchema + invalidEmail",
+ },
+ });
+
+ const mockUI = createMockUI({
+ locale: testLocale,
+ });
+
+ const schema = createForgotPasswordAuthFormSchema(mockUI);
+
+ // Cause the schema to fail...
+ // TODO(ehesp): If no value is provided, the schema error is just "Required" - should this also be translated?
+ const result = schema.safeParse({
+ email: '',
+ });
+
+ expect(result.success).toBe(false);
+ expect(result.error).toBeDefined();
+ expect(result.error?.issues.length).toBe(1);
+
+ expect(result.error?.issues[0]?.message).toBe("createForgotPasswordAuthFormSchema + invalidEmail");
+ });
+});
+
+describe("createEmailLinkAuthFormSchema", () => {
+ it("should create a forgot password form schema with valid error messages", () => {
+ const testLocale = registerLocale('test', {
+ errors: {
+ invalidEmail: "createEmailLinkAuthFormSchema + invalidEmail",
+ },
+ });
+
+ const mockUI = createMockUI({
+ locale: testLocale,
+ });
+
+ const schema = createEmailLinkAuthFormSchema(mockUI);
+
+ // Cause the schema to fail...
+ // TODO(ehesp): If no value is provided, the schema error is just "Required" - should this also be translated?
+ const result = schema.safeParse({
+ email: '',
+ });
+
+ expect(result.success).toBe(false);
+ expect(result.error).toBeDefined();
+ expect(result.error?.issues.length).toBe(1);
+
+ expect(result.error?.issues[0]?.message).toBe("createEmailLinkAuthFormSchema + invalidEmail");
+ });
+});
+
+describe("createPhoneAuthFormSchema", () => {
+ it("should create a phone auth form schema and show missing phone number error", () => {
+ const testLocale = registerLocale('test', {
+ errors: {
+ missingPhoneNumber: "createPhoneAuthFormSchema + missingPhoneNumber",
+ },
+ });
+
+ const mockUI = createMockUI({
+ locale: testLocale,
+ });
+
+ const schema = createPhoneAuthFormSchema(mockUI);
+
+ // Cause the schema to fail...
+ // TODO(ehesp): If no value is provided, the schema error is just "Required" - should this also be translated?
+ const result = schema.safeParse({
+ phoneNumber: '',
+ });
+
+ expect(result.success).toBe(false);
+ expect(result.error).toBeDefined();
+
+ expect(result.error?.issues[0]?.message).toBe("createPhoneAuthFormSchema + missingPhoneNumber");
+ });
+
+ it("should create a phone auth form schema and show an error if the phone number is too long", () => {
+ const testLocale = registerLocale('test', {
+ errors: {
+ invalidPhoneNumber: "createPhoneAuthFormSchema + invalidPhoneNumber",
+ },
+ });
+
+ const mockUI = createMockUI({
+ locale: testLocale,
+ });
+
+ const schema = createPhoneAuthFormSchema(mockUI);
+
+ // Cause the schema to fail...
+ // TODO(ehesp): If no value is provided, the schema error is just "Required" - should this also be translated?
+ const result = schema.safeParse({
+ phoneNumber: '12345678901',
+ verificationCode: '123',
+ recaptchaVerifier: null,
+ });
+
+ expect(result.success).toBe(false);
+ expect(result.error).toBeDefined();
+
+ expect(result.error?.issues[0]?.message).toBe("createPhoneAuthFormSchema + invalidPhoneNumber");
+ });
+
+ it("should create a phone auth form schema and show an error if the verification code is too short", () => {
+ const testLocale = registerLocale('test', {
+ errors: {
+ invalidVerificationCode: "createPhoneAuthFormSchema + invalidVerificationCode",
+ },
+ });
+
+ const mockUI = createMockUI({
+ locale: testLocale,
+ });
+
+ const schema = createPhoneAuthFormSchema(mockUI);
+
+ const result = schema.safeParse({
+ phoneNumber: '1234567890',
+ verificationCode: '123',
+ recaptchaVerifier: {} as RecaptchaVerifier, // Workaround for RecaptchaVerifier failing with Node env.
+ });
+
+ expect(result.success).toBe(false);
+ expect(result.error).toBeDefined();
+ expect(result.error?.issues.some(issue => issue.message === "createPhoneAuthFormSchema + invalidVerificationCode")).toBe(true);
+ });
+});
\ No newline at end of file
diff --git a/packages/core/src/schemas.ts b/packages/core/src/schemas.ts
index 46bcdbeb..14df3d76 100644
--- a/packages/core/src/schemas.ts
+++ b/packages/core/src/schemas.ts
@@ -54,7 +54,7 @@ export function createPhoneAuthFormSchema(ui: FirebaseUIConfiguration) {
phoneNumber: z
.string()
.min(1, { message: getTranslation(ui, "errors", "missingPhoneNumber") })
- .min(10, { message: getTranslation(ui, "errors", "invalidPhoneNumber") }),
+ .max(10, { message: getTranslation(ui, "errors", "invalidPhoneNumber") }),
verificationCode: z.string().refine((val) => !val || val.length >= 6, {
message: getTranslation(ui, "errors", "invalidVerificationCode"),
}),
diff --git a/packages/core/src/translations.test.ts b/packages/core/src/translations.test.ts
new file mode 100644
index 00000000..a7d3d356
--- /dev/null
+++ b/packages/core/src/translations.test.ts
@@ -0,0 +1,29 @@
+import { describe, expect, it, vi } from "vitest";
+
+// Mock the translations module first
+vi.mock("@firebase-ui/translations", async (original) => ({
+ ...(await original()),
+ getTranslation: vi.fn(),
+}));
+
+import { getTranslation as _getTranslation, registerLocale } from "@firebase-ui/translations";
+import { getTranslation } from "./translations";
+import { createMockUI } from "~/tests/utils";
+
+describe("getTranslation", () => {
+ it("should return the correct translation", () => {
+ const testLocale = registerLocale("test", {
+ errors: {
+ userNotFound: "test + userNotFound",
+ },
+ });
+
+ vi.mocked(_getTranslation).mockReturnValue("test + userNotFound");
+
+ const mockUI = createMockUI({ locale: testLocale });
+ const translation = getTranslation(mockUI, "errors", "userNotFound");
+
+ expect(translation).toBe("test + userNotFound");
+ expect(_getTranslation).toHaveBeenCalledWith(testLocale, "errors", "userNotFound");
+ });
+});
\ No newline at end of file
diff --git a/packages/core/tests/integration/auth.integration.test.ts b/packages/core/tests/auth.integration.test.ts
similarity index 97%
rename from packages/core/tests/integration/auth.integration.test.ts
rename to packages/core/tests/auth.integration.test.ts
index f3b86168..e89a5898 100644
--- a/packages/core/tests/integration/auth.integration.test.ts
+++ b/packages/core/tests/auth.integration.test.ts
@@ -24,12 +24,12 @@ import {
sendSignInLinkToEmail,
signInAnonymously,
sendPasswordResetEmail,
- signInWithOAuth,
+ signInWithProvider,
completeEmailLinkSignIn,
confirmPhoneNumber as _confirmPhoneNumber,
-} from "../../src/auth";
-import { FirebaseUIError } from "../../src/errors";
-import { initializeUI, FirebaseUI } from "../../src/config";
+} from "../src/auth";
+import { FirebaseUIError } from "../src/errors";
+import { initializeUI, FirebaseUI } from "../src/config";
describe("Firebase UI Auth Integration", () => {
let auth: Auth;
@@ -141,7 +141,7 @@ describe("Firebase UI Auth Integration", () => {
it("should handle enableAutoUpgradeAnonymous flag for OAuth", async () => {
const provider = new GoogleAuthProvider();
await signInAnonymously(ui.get());
- await expect(signInWithOAuth(ui.get(), provider)).rejects.toThrow();
+ await expect(signInWithProvider(ui.get(), provider)).rejects.toThrow();
});
});
diff --git a/packages/core/tests/unit/auth.test.ts b/packages/core/tests/unit/auth.test.ts
deleted file mode 100644
index 6d1d4313..00000000
--- a/packages/core/tests/unit/auth.test.ts
+++ /dev/null
@@ -1,500 +0,0 @@
-/**
- * Copyright 2025 Google LLC
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-///
-import { describe, it, expect, vi, beforeEach } from "vitest";
-import {
- Auth,
- EmailAuthProvider,
- PhoneAuthProvider,
- createUserWithEmailAndPassword as fbCreateUserWithEmailAndPassword,
- getAuth,
- isSignInWithEmailLink as fbIsSignInWithEmailLink,
- linkWithCredential,
- linkWithRedirect,
- sendPasswordResetEmail as fbSendPasswordResetEmail,
- sendSignInLinkToEmail as fbSendSignInLinkToEmail,
- signInAnonymously as fbSignInAnonymously,
- signInWithCredential,
- signInWithPhoneNumber as fbSignInWithPhoneNumber,
- signInWithRedirect,
-} from "firebase/auth";
-import {
- signInWithEmailAndPassword,
- createUserWithEmailAndPassword,
- signInWithPhoneNumber,
- confirmPhoneNumber,
- sendPasswordResetEmail,
- sendSignInLinkToEmail,
- signInWithEmailLink,
- signInAnonymously,
- signInWithProvider,
- completeEmailLinkSignIn,
-} from "../../src/auth";
-import { FirebaseUIConfiguration } from "../../src/config";
-import { enUs } from "@firebase-ui/translations";
-
-// Mock all Firebase Auth functions
-vi.mock("firebase/auth", async () => {
- const actual = await vi.importActual("firebase/auth");
- return {
- ...(actual as object),
- getAuth: vi.fn(),
- signInWithCredential: vi.fn(),
- createUserWithEmailAndPassword: vi.fn(),
- signInWithPhoneNumber: vi.fn(),
- sendPasswordResetEmail: vi.fn(),
- sendSignInLinkToEmail: vi.fn(),
- isSignInWithEmailLink: vi.fn(),
- signInAnonymously: vi.fn(),
- linkWithCredential: vi.fn(),
- linkWithRedirect: vi.fn(),
- signInWithRedirect: vi.fn(),
- EmailAuthProvider: {
- credential: vi.fn(),
- credentialWithLink: vi.fn(),
- },
- PhoneAuthProvider: {
- credential: vi.fn(),
- },
- };
-});
-
-describe("Firebase UI Auth", () => {
- let mockAuth: Auth;
- let mockUi: FirebaseUIConfiguration;
-
- const mockCredential = { type: "password", token: "mock-token" };
- const mockUserCredential = { user: { uid: "mock-uid" } };
- const mockConfirmationResult = { verificationId: "mock-verification-id" };
- const _mockError = { name: "FirebaseError", code: "auth/user-not-found" };
- const mockProvider = { providerId: "google.com" };
-
- beforeEach(() => {
- vi.clearAllMocks();
- mockAuth = { currentUser: null } as Auth;
- window.localStorage.clear();
- window.sessionStorage.clear();
- (EmailAuthProvider.credential as any).mockReturnValue(mockCredential);
- (EmailAuthProvider.credentialWithLink as any).mockReturnValue(mockCredential);
- (PhoneAuthProvider.credential as any).mockReturnValue(mockCredential);
-
- // Create a mock FirebaseUIConfiguration
- mockUi = {
- app: { name: "test" } as any,
- auth: mockAuth,
- setLocale: vi.fn(),
- state: "idle",
- setState: vi.fn(),
- locale: enUs,
- behaviors: {},
- };
- });
-
- describe("signInWithEmailAndPassword", () => {
- it("should sign in with email and password", async () => {
- (signInWithCredential as any).mockResolvedValue(mockUserCredential);
-
- const result = await signInWithEmailAndPassword(mockUi, "test@test.com", "password");
-
- expect(EmailAuthProvider.credential).toHaveBeenCalledWith("test@test.com", "password");
- expect(signInWithCredential).toHaveBeenCalledWith(mockAuth, mockCredential);
- expect(result).toBe(mockUserCredential);
- });
-
- it("should upgrade anonymous user when enabled", async () => {
- mockAuth = { currentUser: { isAnonymous: true } } as Auth;
- (getAuth as any).mockReturnValue(mockAuth);
- (linkWithCredential as any).mockResolvedValue(mockUserCredential);
-
- mockUi.behaviors.autoUpgradeAnonymousCredential = vi.fn().mockResolvedValue(mockUserCredential);
-
- const result = await signInWithEmailAndPassword(mockUi, "test@test.com", "password");
-
- expect(mockUi.behaviors.autoUpgradeAnonymousCredential).toHaveBeenCalledWith(mockUi, mockCredential);
- expect(result).toBe(mockUserCredential);
- });
- });
-
- describe("createUserWithEmailAndPassword", () => {
- it("should create user with email and password", async () => {
- (fbCreateUserWithEmailAndPassword as any).mockResolvedValue(mockUserCredential);
-
- const result = await createUserWithEmailAndPassword(mockUi, "test@test.com", "password");
-
- expect(fbCreateUserWithEmailAndPassword).toHaveBeenCalledWith(mockAuth, "test@test.com", "password");
- expect(result).toBe(mockUserCredential);
- });
-
- it("should upgrade anonymous user when enabled", async () => {
- mockAuth = { currentUser: { isAnonymous: true } } as Auth;
- (getAuth as any).mockReturnValue(mockAuth);
- (linkWithCredential as any).mockResolvedValue(mockUserCredential);
-
- mockUi.behaviors.autoUpgradeAnonymousCredential = vi.fn().mockResolvedValue(mockUserCredential);
-
- const result = await createUserWithEmailAndPassword(mockUi, "test@test.com", "password");
-
- expect(mockUi.behaviors.autoUpgradeAnonymousCredential).toHaveBeenCalledWith(mockUi, mockCredential);
- expect(result).toBe(mockUserCredential);
- });
- });
-
- describe("signInWithPhoneNumber", () => {
- it("should initiate phone number sign in", async () => {
- (fbSignInWithPhoneNumber as any).mockResolvedValue(mockConfirmationResult);
- const mockRecaptcha = { type: "recaptcha" };
-
- const result = await signInWithPhoneNumber(mockUi, "+1234567890", mockRecaptcha as any);
-
- expect(fbSignInWithPhoneNumber).toHaveBeenCalledWith(mockAuth, "+1234567890", mockRecaptcha);
- expect(result).toBe(mockConfirmationResult);
- });
- });
-
- describe("confirmPhoneNumber", () => {
- it("should confirm phone number sign in", async () => {
- (signInWithCredential as any).mockResolvedValue(mockUserCredential);
-
- const result = await confirmPhoneNumber(mockUi, { verificationId: "mock-id" } as any, "123456");
-
- expect(PhoneAuthProvider.credential).toHaveBeenCalledWith("mock-id", "123456");
- expect(signInWithCredential).toHaveBeenCalledWith(mockAuth, mockCredential);
- expect(result).toBe(mockUserCredential);
- });
-
- it("should upgrade anonymous user when enabled", async () => {
- mockAuth = { currentUser: { isAnonymous: true } } as Auth;
- (getAuth as any).mockReturnValue(mockAuth);
- (linkWithCredential as any).mockResolvedValue(mockUserCredential);
-
- mockUi.behaviors.autoUpgradeAnonymousCredential = vi.fn().mockResolvedValue(mockUserCredential);
-
- const result = await confirmPhoneNumber(mockUi, { verificationId: "mock-id" } as any, "123456");
-
- expect(mockUi.behaviors.autoUpgradeAnonymousCredential).toHaveBeenCalled();
- expect(result).toBe(mockUserCredential);
- });
- });
-
- describe("sendPasswordResetEmail", () => {
- it("should send password reset email", async () => {
- (fbSendPasswordResetEmail as any).mockResolvedValue(undefined);
-
- await sendPasswordResetEmail(mockUi, "test@test.com");
-
- expect(fbSendPasswordResetEmail).toHaveBeenCalledWith(mockAuth, "test@test.com");
- });
- });
-
- describe("sendSignInLinkToEmail", () => {
- it("should send sign in link to email", async () => {
- (fbSendSignInLinkToEmail as any).mockResolvedValue(undefined);
-
- const expectedActionCodeSettings = {
- url: window.location.href,
- handleCodeInApp: true,
- };
-
- await sendSignInLinkToEmail(mockUi, "test@test.com");
-
- expect(fbSendSignInLinkToEmail).toHaveBeenCalledWith(mockAuth, "test@test.com", expectedActionCodeSettings);
- expect(mockUi.setState).toHaveBeenCalledWith("sending-sign-in-link-to-email");
- expect(mockUi.setState).toHaveBeenCalledWith("idle");
- expect(window.localStorage.getItem("emailForSignIn")).toBe("test@test.com");
- });
- });
-
- describe("signInWithEmailLink", () => {
- it("should sign in with email link", async () => {
- (signInWithCredential as any).mockResolvedValue(mockUserCredential);
-
- const result = await signInWithEmailLink(mockUi, "test@test.com", "mock-link");
-
- expect(EmailAuthProvider.credentialWithLink).toHaveBeenCalledWith("test@test.com", "mock-link");
- expect(signInWithCredential).toHaveBeenCalledWith(mockAuth, mockCredential);
- expect(result).toBe(mockUserCredential);
- });
-
- it("should upgrade anonymous user when enabled", async () => {
- mockAuth = { currentUser: { isAnonymous: true } } as Auth;
- (getAuth as any).mockReturnValue(mockAuth);
- window.localStorage.setItem("emailLinkAnonymousUpgrade", "true");
- (linkWithCredential as any).mockResolvedValue(mockUserCredential);
-
- mockUi.behaviors.autoUpgradeAnonymousCredential = vi.fn().mockResolvedValue(mockUserCredential);
-
- const result = await signInWithEmailLink(mockUi, "test@test.com", "mock-link");
-
- expect(mockUi.behaviors.autoUpgradeAnonymousCredential).toHaveBeenCalled();
- expect(result).toBe(mockUserCredential);
- });
- });
-
- describe("signInAnonymously", () => {
- it("should sign in anonymously", async () => {
- (fbSignInAnonymously as any).mockResolvedValue(mockUserCredential);
-
- const result = await signInAnonymously(mockUi);
-
- expect(fbSignInAnonymously).toHaveBeenCalledWith(mockAuth);
- expect(result).toBe(mockUserCredential);
- });
-
- it("should handle operation not allowed error", async () => {
- const operationNotAllowedError = { name: "FirebaseError", code: "auth/operation-not-allowed" };
- (fbSignInAnonymously as any).mockRejectedValue(operationNotAllowedError);
-
- await expect(signInAnonymously(mockUi)).rejects.toThrow();
- });
-
- it("should handle admin restricted operation error", async () => {
- const adminRestrictedError = { name: "FirebaseError", code: "auth/admin-restricted-operation" };
- (fbSignInAnonymously as any).mockRejectedValue(adminRestrictedError);
-
- await expect(signInAnonymously(mockUi)).rejects.toThrow();
- });
- });
-
- describe("Anonymous User Upgrade", () => {
- it("should handle upgrade with existing email", async () => {
- mockAuth = { currentUser: { isAnonymous: true } } as Auth;
- (getAuth as any).mockReturnValue(mockAuth);
- const emailExistsError = { name: "FirebaseError", code: "auth/email-already-in-use" };
- (fbCreateUserWithEmailAndPassword as any).mockRejectedValue(emailExistsError);
-
- await expect(createUserWithEmailAndPassword(mockUi, "existing@test.com", "password")).rejects.toThrow();
- });
-
- it("should handle upgrade of non-anonymous user", async () => {
- mockAuth = { currentUser: { isAnonymous: false } } as Auth;
- (getAuth as any).mockReturnValue(mockAuth);
- (fbCreateUserWithEmailAndPassword as any).mockResolvedValue(mockUserCredential);
-
- const result = await createUserWithEmailAndPassword(mockUi, "test@test.com", "password");
-
- expect(fbCreateUserWithEmailAndPassword).toHaveBeenCalledWith(mockAuth, "test@test.com", "password");
- expect(result).toBe(mockUserCredential);
- });
-
- it("should handle null user during upgrade", async () => {
- mockAuth = { currentUser: null } as Auth;
- (getAuth as any).mockReturnValue(mockAuth);
- (fbCreateUserWithEmailAndPassword as any).mockResolvedValue(mockUserCredential);
-
- const result = await createUserWithEmailAndPassword(mockUi, "test@test.com", "password");
-
- expect(fbCreateUserWithEmailAndPassword).toHaveBeenCalledWith(mockAuth, "test@test.com", "password");
- expect(result).toBe(mockUserCredential);
- });
- });
-
- describe("signInWithProvider", () => {
- it("should sign in with OAuth provider", async () => {
- (signInWithRedirect as any).mockResolvedValue(undefined);
-
- await signInWithProvider(mockUi, mockProvider as any);
-
- expect(signInWithRedirect).toHaveBeenCalledWith(mockAuth, mockProvider);
- });
-
- it("should upgrade anonymous user when enabled", async () => {
- mockAuth = { currentUser: { isAnonymous: true } } as Auth;
- (getAuth as any).mockReturnValue(mockAuth);
- (linkWithRedirect as any).mockResolvedValue(undefined);
-
- mockUi.behaviors.autoUpgradeAnonymousProvider = vi.fn();
-
- await signInWithProvider(mockUi, mockProvider as any);
-
- expect(mockUi.behaviors.autoUpgradeAnonymousProvider).toHaveBeenCalledWith(mockUi, mockProvider);
- });
- });
-
- describe("completeEmailLinkSignIn", () => {
- it("should complete email link sign in when valid", async () => {
- (fbIsSignInWithEmailLink as any).mockReturnValue(true);
- window.localStorage.setItem("emailForSignIn", "test@test.com");
- (signInWithCredential as any).mockResolvedValue(mockUserCredential);
-
- const result = await completeEmailLinkSignIn(mockUi, "https://example.com?oob=code");
-
- expect(fbIsSignInWithEmailLink).toHaveBeenCalled();
- expect(result).toBe(mockUserCredential);
- });
-
- it("should clean up all storage items after sign in attempt", async () => {
- (fbIsSignInWithEmailLink as any).mockReturnValue(true);
- window.localStorage.setItem("emailForSignIn", "test@test.com");
- (signInWithCredential as any).mockResolvedValue(mockUserCredential);
-
- await completeEmailLinkSignIn(mockUi, "https://example.com?oob=code");
-
- expect(window.localStorage.getItem("emailForSignIn")).toBeNull();
- });
-
- it("should return null when not a valid sign in link", async () => {
- (fbIsSignInWithEmailLink as any).mockReturnValue(false);
-
- const result = await completeEmailLinkSignIn(mockUi, "https://example.com?invalidlink=true");
-
- expect(result).toBeNull();
- });
-
- it("should return null when no email in storage", async () => {
- (fbIsSignInWithEmailLink as any).mockReturnValue(true);
- window.localStorage.clear();
-
- const result = await completeEmailLinkSignIn(mockUi, "https://example.com?oob=code");
-
- expect(result).toBeNull();
- });
-
- it("should clean up storage even when sign in fails", async () => {
- // Patch localStorage for testing
- const mockLocalStorage = {
- getItem: vi.fn().mockReturnValue("test@test.com"),
- removeItem: vi.fn(),
- setItem: vi.fn(),
- clear: vi.fn(),
- key: vi.fn(),
- length: 0,
- };
- Object.defineProperty(window, "localStorage", { value: mockLocalStorage });
-
- // Make isSignInWithEmailLink return true
- (fbIsSignInWithEmailLink as any).mockReturnValue(true);
-
- // Make signInWithCredential throw an error
- const error = new Error("Sign in failed");
- (signInWithCredential as any).mockRejectedValue(error);
-
- // Mock handleFirebaseError to throw our actual error instead
- vi.mock("../../src/errors", async () => {
- const actual = await vi.importActual("../../src/errors");
- return {
- ...(actual as object),
- handleFirebaseError: vi.fn().mockImplementation((ui, e) => {
- throw e;
- }),
- };
- });
-
- // Use rejects matcher with our specific error
- await expect(completeEmailLinkSignIn(mockUi, "https://example.com?oob=code")).rejects.toThrow("Sign in failed");
-
- // Check localStorage was cleared
- expect(mockLocalStorage.removeItem).toHaveBeenCalledWith("emailForSignIn");
- });
- });
-
- describe("Pending Credential Handling", () => {
- it("should handle pending credential during email sign in", async () => {
- (signInWithCredential as any).mockResolvedValue(mockUserCredential);
- window.sessionStorage.setItem("pendingCred", JSON.stringify(mockCredential));
- (linkWithCredential as any).mockResolvedValue({ ...mockUserCredential, linked: true });
-
- const result = await signInWithEmailAndPassword(mockUi, "test@test.com", "password");
-
- expect(linkWithCredential).toHaveBeenCalledWith(mockUserCredential.user, mockCredential);
- expect((result as any).linked).toBe(true);
- expect(window.sessionStorage.getItem("pendingCred")).toBeNull();
- });
-
- it("should handle invalid pending credential gracefully", async () => {
- (signInWithCredential as any).mockResolvedValue(mockUserCredential);
- window.sessionStorage.setItem("pendingCred", "invalid-json");
-
- const result = await signInWithEmailAndPassword(mockUi, "test@test.com", "password");
-
- expect(result).toBe(mockUserCredential);
- });
-
- it("should handle linking failure gracefully", async () => {
- (signInWithCredential as any).mockResolvedValue(mockUserCredential);
- window.sessionStorage.setItem("pendingCred", JSON.stringify(mockCredential));
- (linkWithCredential as any).mockRejectedValue(new Error("Linking failed"));
-
- const result = await signInWithEmailAndPassword(mockUi, "test@test.com", "password");
-
- expect(result).toBe(mockUserCredential);
- expect(window.sessionStorage.getItem("pendingCred")).toBeNull();
- });
- });
-
- describe("Storage Management", () => {
- it("should clean up all storage items after successful email link sign in", async () => {
- (fbIsSignInWithEmailLink as any).mockReturnValue(true);
-
- // Patch localStorage for testing
- const mockLocalStorage = {
- getItem: vi.fn().mockReturnValue("test@test.com"),
- removeItem: vi.fn(),
- setItem: vi.fn(),
- clear: vi.fn(),
- key: vi.fn(),
- length: 0,
- };
- Object.defineProperty(window, "localStorage", { value: mockLocalStorage });
-
- // Create mocks to ensure a successful sign in
- (signInWithCredential as any).mockResolvedValue(mockUserCredential);
- (EmailAuthProvider.credentialWithLink as any).mockReturnValue(mockCredential);
-
- const result = await completeEmailLinkSignIn(mockUi, "https://example.com?oob=code");
-
- expect(result).not.toBeNull();
- expect(mockLocalStorage.removeItem).toHaveBeenCalledWith("emailForSignIn");
- });
-
- it("should clean up storage even when sign in fails", async () => {
- // Patch localStorage for testing
- const mockLocalStorage = {
- getItem: vi.fn().mockReturnValue("test@test.com"),
- removeItem: vi.fn(),
- setItem: vi.fn(),
- clear: vi.fn(),
- key: vi.fn(),
- length: 0,
- };
- Object.defineProperty(window, "localStorage", { value: mockLocalStorage });
-
- // Make isSignInWithEmailLink return true
- (fbIsSignInWithEmailLink as any).mockReturnValue(true);
-
- // Make signInWithCredential throw an error
- const error = new Error("Sign in failed");
- (signInWithCredential as any).mockRejectedValue(error);
-
- // Mock handleFirebaseError to throw our actual error instead
- vi.mock("../../src/errors", async () => {
- const actual = await vi.importActual("../../src/errors");
- return {
- ...(actual as object),
- handleFirebaseError: vi.fn().mockImplementation((ui, e) => {
- throw e;
- }),
- };
- });
-
- // Use rejects matcher with our specific error
- await expect(completeEmailLinkSignIn(mockUi, "https://example.com?oob=code")).rejects.toThrow("Sign in failed");
-
- // Check localStorage was cleared
- expect(mockLocalStorage.removeItem).toHaveBeenCalledWith("emailForSignIn");
- });
- });
-});
diff --git a/packages/core/tests/unit/config.test.ts b/packages/core/tests/unit/config.test.ts
deleted file mode 100644
index deff8fd3..00000000
--- a/packages/core/tests/unit/config.test.ts
+++ /dev/null
@@ -1,146 +0,0 @@
-/**
- * Copyright 2025 Google LLC
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import { describe, it, expect, vi } from "vitest";
-import { initializeUI, $config } from "../../src/config";
-import { enUs } from "@firebase-ui/translations";
-import { onAuthStateChanged as _onAuthStateChanged } from "firebase/auth";
-
-vi.mock("firebase/auth", () => ({
- getAuth: vi.fn(),
- onAuthStateChanged: vi.fn(),
-}));
-
-describe("Config", () => {
- describe("initializeUI", () => {
- it("should initialize config with default name", () => {
- const config = {
- app: {
- name: "test",
- options: {},
- automaticDataCollectionEnabled: false,
- },
- };
-
- const store = initializeUI(config);
- expect(store.get()).toEqual({
- app: config.app,
- getAuth: expect.any(Function),
- locale: enUs,
- setLocale: expect.any(Function),
- state: "idle",
- setState: expect.any(Function),
- behaviors: {},
- recaptchaMode: "normal",
- });
- expect($config.get()["[DEFAULT]"]).toBe(store);
- });
-
- it("should initialize config with custom name", () => {
- const config = {
- app: {
- name: "test",
- options: {},
- automaticDataCollectionEnabled: false,
- },
- };
-
- const store = initializeUI(config, "custom");
- expect(store.get()).toEqual({
- app: config.app,
- getAuth: expect.any(Function),
- locale: enUs,
- setLocale: expect.any(Function),
- state: "idle",
- setState: expect.any(Function),
- behaviors: {},
- recaptchaMode: "normal",
- });
- expect($config.get()["custom"]).toBe(store);
- });
-
- it("should setup auto anonymous login when enabled", () => {
- const config = {
- app: {
- name: "test",
- options: {},
- automaticDataCollectionEnabled: false,
- },
- behaviors: [
- {
- autoAnonymousLogin: vi.fn().mockImplementation(async (ui) => {
- ui.setState("idle");
- return {};
- }),
- },
- ],
- };
-
- const store = initializeUI(config);
- expect(store.get().behaviors.autoAnonymousLogin).toBeDefined();
- expect(store.get().behaviors.autoAnonymousLogin).toHaveBeenCalled();
- expect(store.get().state).toBe("idle");
- });
-
- it("should not setup auto anonymous login when disabled", () => {
- const config = {
- app: {
- name: "test",
- options: {},
- automaticDataCollectionEnabled: false,
- },
- };
-
- const store = initializeUI(config);
- expect(store.get().behaviors.autoAnonymousLogin).toBeUndefined();
- });
-
- it("should handle both auto features being enabled", () => {
- const config = {
- app: {
- name: "test",
- options: {},
- automaticDataCollectionEnabled: false,
- },
- behaviors: [
- {
- autoAnonymousLogin: vi.fn().mockImplementation(async (ui) => {
- ui.setState("idle");
- return {};
- }),
- autoUpgradeAnonymousCredential: vi.fn(),
- },
- ],
- };
-
- const store = initializeUI(config);
- expect(store.get()).toEqual({
- app: config.app,
- getAuth: expect.any(Function),
- locale: enUs,
- setLocale: expect.any(Function),
- state: "idle",
- setState: expect.any(Function),
- behaviors: {
- autoAnonymousLogin: expect.any(Function),
- autoUpgradeAnonymousCredential: expect.any(Function),
- },
- recaptchaMode: "normal",
- });
- expect(store.get().behaviors.autoAnonymousLogin).toHaveBeenCalled();
- });
- });
-});
diff --git a/packages/core/tests/unit/errors.test.ts b/packages/core/tests/unit/errors.test.ts
deleted file mode 100644
index c8c6b9b7..00000000
--- a/packages/core/tests/unit/errors.test.ts
+++ /dev/null
@@ -1,260 +0,0 @@
-/**
- * Copyright 2025 Google LLC
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import { describe, it, expect, vi as _vi } from "vitest";
-import { FirebaseUIError, handleFirebaseError } from "../../src/errors";
-import { enUs } from "@firebase-ui/translations";
-import { FirebaseUIConfiguration } from "../../src/config";
-
-const mockUi = {
- locale: enUs,
-} as FirebaseUIConfiguration;
-
-describe("FirebaseUIError", () => {
- describe("constructor", () => {
- it("should extract error code from Firebase error message", () => {
- const error = new FirebaseUIError({
- customData: { message: "Firebase: Error (auth/wrong-password)." },
- }, mockUi);
- expect(error.code).toBe("auth/wrong-password");
- });
-
- it("should use error code directly if available", () => {
- const error = new FirebaseUIError({ code: "auth/user-not-found" }, mockUi);
- expect(error.code).toBe("auth/user-not-found");
- });
-
- it("should fallback to unknown if no code is found", () => {
- const error = new FirebaseUIError({}, mockUi);
- expect(error.code).toBe("unknown");
- });
-
- // TODO: Create util for another language
- it.skip("should use custom translations if provided", () => {
- const translations = {
- "es-ES": {
- errors: {
- userNotFound: "Usuario no encontrado",
- },
- },
- };
- const error = new FirebaseUIError({ code: "auth/user-not-found" }, mockUi);
- expect(error.message).toBe("Usuario no encontrado");
- });
-
- it("should fallback to default translation if language is not found", () => {
- const error = new FirebaseUIError({ code: "auth/user-not-found" }, mockUi);
- expect(error.message).toBe("No account found with this email address");
- });
-
- it("should handle malformed error objects gracefully", () => {
- const error = new FirebaseUIError(null, mockUi);
- expect(error.code).toBe("unknown");
- expect(error.message).toBe("An unexpected error occurred");
- });
-
- it("should set error name to FirebaseUIError", () => {
- const error = new FirebaseUIError({}, mockUi);
- expect(error.name).toBe("FirebaseUIError");
- });
- });
-
- describe("handleFirebaseError", () => {
- // const mockUi = {
- // translations: {
- // "es-ES": {
- // errors: {
- // userNotFound: "Usuario no encontrado",
- // },
- // },
- // },
- // locale: "es-ES",
- // };
-
- // TODO: Create util for another language
- it.skip("should throw FirebaseUIError for Firebase errors", () => {
- const firebaseError = {
- name: "FirebaseError",
- code: "auth/user-not-found",
- };
-
- expect(() => {
- handleFirebaseError(mockUi as any, firebaseError);
- }).toThrow(FirebaseUIError);
-
- try {
- handleFirebaseError(mockUi as any, firebaseError);
- } catch (e) {
- expect(e).toBeInstanceOf(FirebaseUIError);
- expect(e.code).toBe("auth/user-not-found");
- expect(e.message).toBe("Usuario no encontrado");
- }
- });
-
- it("should throw FirebaseUIError with unknown code for non-Firebase errors", () => {
- const error = new Error("Random error");
-
- expect(() => {
- handleFirebaseError(mockUi as any, error);
- }).toThrow(FirebaseUIError);
-
- try {
- handleFirebaseError(mockUi as any, error);
- } catch (e) {
- expect(e).toBeInstanceOf(FirebaseUIError);
- expect(e.code).toBe("unknown");
- }
- });
-
- // TODO: Create util for another language
- it.skip("should pass translations and language to FirebaseUIError", () => {
- const firebaseError = {
- name: "FirebaseError",
- code: "auth/user-not-found",
- };
-
- expect(() => {
- handleFirebaseError(mockUi as any, firebaseError);
- }).toThrow(FirebaseUIError);
-
- try {
- handleFirebaseError(mockUi as any, firebaseError);
- } catch (e) {
- expect(e).toBeInstanceOf(FirebaseUIError);
- expect(e.message).toBe("Usuario no encontrado");
- }
- });
-
- it("should handle null/undefined errors", () => {
- expect(() => {
- handleFirebaseError(mockUi as any, null);
- }).toThrow(FirebaseUIError);
-
- try {
- handleFirebaseError(mockUi as any, null);
- } catch (e) {
- expect(e).toBeInstanceOf(FirebaseUIError);
- expect(e.code).toBe("unknown");
- }
- });
-
- it("should preserve the error code in thrown error", () => {
- const firebaseError = {
- name: "FirebaseError",
- code: "auth/wrong-password",
- };
-
- expect(() => {
- handleFirebaseError(mockUi as any, firebaseError);
- }).toThrow(FirebaseUIError);
-
- try {
- handleFirebaseError(mockUi as any, firebaseError);
- } catch (e) {
- expect(e).toBeInstanceOf(FirebaseUIError);
- expect(e.code).toBe("auth/wrong-password");
- }
- });
-
- describe("account exists with different credential handling", () => {
- it("should store credential and throw error when enableHandleExistingCredential is true", () => {
- const mockCredential = { type: "google.com" };
- const error = {
- code: "auth/account-exists-with-different-credential",
- credential: mockCredential,
- customData: { email: "test@test.com" },
- };
-
- expect(() => {
- handleFirebaseError(mockUi as any, error, { enableHandleExistingCredential: true });
- }).toThrow(FirebaseUIError);
-
- try {
- handleFirebaseError(mockUi as any, error, { enableHandleExistingCredential: true });
- } catch (e) {
- expect(e).toBeInstanceOf(FirebaseUIError);
- expect(e.code).toBe("auth/account-exists-with-different-credential");
- expect(window.sessionStorage.getItem("pendingCred")).toBe(JSON.stringify(mockCredential));
- }
- });
-
- it("should not store credential when enableHandleExistingCredential is false", () => {
- const mockCredential = { type: "google.com" };
- const error = {
- code: "auth/account-exists-with-different-credential",
- credential: mockCredential,
- };
-
- expect(() => {
- handleFirebaseError(mockUi as any, error);
- }).toThrow(FirebaseUIError);
-
- try {
- handleFirebaseError(mockUi as any, error);
- } catch (_e) {
- expect(window.sessionStorage.getItem("pendingCred")).toBeNull();
- }
- });
-
- it("should not store credential when no credential in error", () => {
- const error = {
- code: "auth/account-exists-with-different-credential",
- };
-
- expect(() => {
- handleFirebaseError(mockUi as any, error, { enableHandleExistingCredential: true });
- }).toThrow(FirebaseUIError);
-
- try {
- handleFirebaseError(mockUi as any, error, { enableHandleExistingCredential: true });
- } catch (_e) {
- expect(window.sessionStorage.getItem("pendingCred")).toBeNull();
- }
- });
-
- // TODO: Create util for another language
- it.skip("should include email in error and use translations when provided", () => {
- const error = {
- code: "auth/account-exists-with-different-credential",
- customData: { email: "test@test.com" },
- };
-
- const customUi = {
- translations: {
- "es-ES": {
- errors: {
- accountExistsWithDifferentCredential: "La cuenta ya existe con otras credenciales",
- },
- },
- },
- locale: "es-ES",
- };
-
- expect(() => {
- handleFirebaseError(customUi as any, error, { enableHandleExistingCredential: true });
- }).toThrow(FirebaseUIError);
-
- try {
- handleFirebaseError(customUi as any, error, { enableHandleExistingCredential: true });
- } catch (e) {
- expect(e).toBeInstanceOf(FirebaseUIError);
- expect(e.code).toBe("auth/account-exists-with-different-credential");
- expect(e.message).toBe("La cuenta ya existe con otras credenciales");
- }
- });
- });
- });
-});
diff --git a/packages/core/tests/unit/translations.test.ts b/packages/core/tests/unit/translations.test.ts
deleted file mode 100644
index eff1de24..00000000
--- a/packages/core/tests/unit/translations.test.ts
+++ /dev/null
@@ -1,146 +0,0 @@
-/**
- * Copyright 2025 Google LLC
- *
- * Licensed under the Apache License, Version 2.0 (the "License");
- * you may not use this file except in compliance with the License.
- * You may obtain a copy of the License at
- *
- * http://www.apache.org/licenses/LICENSE-2.0
- *
- * Unless required by applicable law or agreed to in writing, software
- * distributed under the License is distributed on an "AS IS" BASIS,
- * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
- * See the License for the specific language governing permissions and
- * limitations under the License.
- */
-
-import { describe, it, expect, vi as _vi } from "vitest";
-import { getTranslation } from "../../src/translations";
-import { enUs } from "@firebase-ui/translations";
-
-describe("getTranslation", () => {
- it("should return default English translation when no custom translations provided", () => {
- const mockUi = {
- locale: enUs,
- };
-
- const translation = getTranslation(mockUi as any, "errors", "userNotFound");
- expect(translation).toBe("No account found with this email address");
- });
-
- // TODO: Create util for another language
- it.skip("should use custom translation when provided", () => {
- const mockUi = {
- translations: {
- "es-ES": {
- errors: {
- userNotFound: "Usuario no encontrado",
- },
- },
- },
- locale: "es-ES",
- };
-
- const translation = getTranslation(mockUi as any, "errors", "userNotFound");
- expect(translation).toBe("Usuario no encontrado");
- });
-
- it.skip("should use custom translation in specified language", () => {
- const mockUi = {
- translations: {
- "es-ES": {
- errors: {
- userNotFound: "Usuario no encontrado",
- },
- },
- // "en-US": english.translations,
- },
- locale: "es-ES",
- };
-
- const translation = getTranslation(mockUi as any, "errors", "userNotFound");
- expect(translation).toBe("Usuario no encontrado");
- });
-
- // TODO: Create util for another language
- it.skip("should fallback to English when specified language is not available", () => {
- const mockUi = {
- translations: {
- // "en-US": english.translations,
- },
- locale: "fr-FR",
- };
-
- const translation = getTranslation(mockUi as any, "errors", "userNotFound");
- expect(translation).toBe("No account found with this email address");
- });
-
- it.skip("should fallback to default English when no custom translations match", () => {
- const mockUi = {
- translations: {
- "es-ES": {
- errors: {},
- },
- },
- locale: "es-ES",
- };
-
- const translation = getTranslation(mockUi as any, "errors", "userNotFound");
- expect(translation).toBe("No account found with this email address");
- });
-
- it.skip("should work with different translation categories", () => {
- const mockUi = {
- translations: {
- // "en-US": english.translations,
- },
- locale: "en-US",
- };
-
- const errorTranslation = getTranslation(mockUi as any, "errors", "userNotFound");
- const labelTranslation = getTranslation(mockUi as any, "labels", "signIn");
-
- expect(errorTranslation).toBe("No account found with this email address");
- expect(labelTranslation).toBe("Sign In");
- });
-
- it.skip("should handle partial custom translations", () => {
- const mockUi = {
- translations: {
- "es-ES": {
- errors: {
- userNotFound: "Usuario no encontrado",
- },
- },
- // "en-US": english.translations,
- },
- locale: "es-ES",
- };
-
- const translation1 = getTranslation(mockUi as any, "errors", "userNotFound");
- const translation2 = getTranslation(mockUi as any, "errors", "unknownError");
-
- expect(translation1).toBe("Usuario no encontrado");
- expect(translation2).toBe("An unexpected error occurred");
- });
-
- it.skip("should handle empty custom translations object", () => {
- const mockUi = {
- translations: {},
- locale: "en-US",
- };
-
- const translation = getTranslation(mockUi as any, "errors", "userNotFound");
- expect(translation).toBe("No account found with this email address");
- });
-
- it.skip("should handle undefined custom translations", () => {
- const mockUi = {
- translations: undefined,
- locale: enUs,
- };
-
- const translation = getTranslation(mockUi as any, "errors", "userNotFound");
- expect(translation).toBe("No account found with this email address");
- });
-});
diff --git a/packages/core/tests/utils.ts b/packages/core/tests/utils.ts
new file mode 100644
index 00000000..5c4360f9
--- /dev/null
+++ b/packages/core/tests/utils.ts
@@ -0,0 +1,19 @@
+import { vi } from "vitest";
+
+import type { FirebaseApp } from "firebase/app";
+import type { Auth } from "firebase/auth";
+import { enUs } from "@firebase-ui/translations";
+import { FirebaseUIConfiguration } from "../src/config";
+
+export function createMockUI(overrides?: Partial): FirebaseUIConfiguration {
+ return {
+ app: {} as FirebaseApp,
+ auth: {} as Auth,
+ setLocale: vi.fn(),
+ state: "idle",
+ setState: vi.fn(),
+ locale: enUs,
+ behaviors: {},
+ ...overrides,
+ };
+}
\ No newline at end of file
diff --git a/packages/core/tsconfig.json b/packages/core/tsconfig.json
index 108ca38f..da2439da 100644
--- a/packages/core/tsconfig.json
+++ b/packages/core/tsconfig.json
@@ -31,6 +31,7 @@
"baseUrl": ".",
"paths": {
"~/*": ["./src/*"],
+ "~/tests/*": ["./tests/*"],
"@firebase-ui/translations": ["../translations/src/index.ts"]
}
},
diff --git a/packages/core/vitest.config.ts b/packages/core/vitest.config.ts
index fbb4fb2b..b633fe9f 100644
--- a/packages/core/vitest.config.ts
+++ b/packages/core/vitest.config.ts
@@ -15,14 +15,13 @@
*/
import { defineConfig } from "vitest/config";
+import tsconfigPaths from 'vite-tsconfig-paths'
export default defineConfig({
test: {
- // Use the same environment as the package
+ name: '@firebase-ui/core',
environment: "jsdom",
- // Include TypeScript files
- include: ["tests/**/*.{test,spec}.{js,ts}"],
- // Exclude build output and node_modules
exclude: ["node_modules/**/*", "dist/**/*"],
},
+ plugins: [tsconfigPaths()],
});