diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 933e5e818..568664f05 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -112,6 +112,44 @@ jobs: - name: Test browser run: npm run test:${{ matrix.browser }} + # The package is built against firebase 12, the oldest major the peer range accepts. This job + # runs that build's specs against firebase 13. firebase 13 requires Node 24.12 or newer. + firebase-13: + runs-on: ubuntu-latest + needs: build + name: Test firebase 13 + steps: + - name: Checkout + uses: actions/checkout@9c091bb21b7c1c1d1991bb908d89e4e9dddfe3e0 # v7.0.0 + with: + persist-credentials: false + - name: Setup node + uses: actions/setup-node@48b55a011bda9f5d6aeb4c2d9c7362e8dae4041e # v6.4.0 + with: + node-version: '>=24.15.0 <25' + check-latest: false + - name: Setup java + uses: actions/setup-java@1bcf9fb12cf4aa7d266a90ae39939e61372fe520 # v5.4.0 + with: + distribution: 'temurin' + java-version: '21' + - name: Install deps + run: | + npm ci + npm install --no-save firebase@13.1.0 + node -e "const version = require('firebase/package.json').version; console.log('firebase', version); process.exit(version === '13.1.0' ? 0 : 1)" + - name: Download Artifacts + uses: actions/download-artifact@3e5f45b2cfb9172054b4087a40e8e0b5a5461e7c # v8.0.1 + with: + name: angularfire-${{ github.run_id }} + path: dist + - name: Test Node + run: | + npm run build:jasmine + npm run test:node + - name: Test headless + run: npm run test:chrome-headless + contribute: runs-on: ${{ matrix.os }} name: Contribute Node ${{ matrix.node }} (${{ matrix.os }}) @@ -154,14 +192,14 @@ jobs: test_and_contribute: runs-on: ubuntu-latest name: Branch protection - needs: ['test', 'contribute', 'browser'] + needs: ['test', 'contribute', 'browser', 'firebase-13'] steps: - run: true publish: runs-on: ubuntu-latest name: Publish (NPM) - needs: ['build', 'test', 'browser'] + needs: ['build', 'test', 'browser', 'firebase-13'] if: ${{ github.ref == 'refs/heads/main' || github.event_name == 'release' }} # One npm publish at a time, each held until npm lists it (about 20 minutes at most), so every # check below reads what the previous publish wrote. diff --git a/package-lock.json b/package-lock.json index 140c4ce34..477131538 100644 --- a/package-lock.json +++ b/package-lock.json @@ -30,7 +30,7 @@ "jsonc-parser": "^3.0.0", "open": "^7.0.3 || ^8.0.0", "ora": "^5.3.0", - "rxfire": "^6.2.0", + "rxfire": "^6.3.0", "rxjs": "~7.8.0", "semver": "^7.1.3", "triple-beam": "^1.3.0", @@ -17026,12 +17026,12 @@ } }, "node_modules/rxfire": { - "version": "6.2.0", - "resolved": "https://registry.npmjs.org/rxfire/-/rxfire-6.2.0.tgz", - "integrity": "sha512-XSRdYjV6rZJUbUL2IpTqLtgnhNHDp9j2KSHZW04R+/ODKm8Ir2ag8I+kVZCq6j1NCclB4JMx60sgSDWcvDYR3g==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/rxfire/-/rxfire-6.3.0.tgz", + "integrity": "sha512-qXRB5KrxaRjho+A33KZyTxvhG2JvxiQMw+mvV4wHXE0OH91NTPH3AZ5hyUV0urd7d9Thxf2norsGaqpihfFMGQ==", "license": "Apache-2.0", "peerDependencies": { - "firebase": "^9.0.0 || ^10.0.0 || ^11.0.0 || ^12.0.0", + "firebase": "^9.0.0 || ^10.0.0 || ^11.0.0 || ^12.0.0 || ^13.0.0", "rxjs": "^6.0.0 || ^7.0.0" } }, diff --git a/package.json b/package.json index a64003501..06adec1d5 100644 --- a/package.json +++ b/package.json @@ -72,7 +72,7 @@ "jsonc-parser": "^3.0.0", "open": "^7.0.3 || ^8.0.0", "ora": "^5.3.0", - "rxfire": "^6.2.0", + "rxfire": "^6.3.0", "rxjs": "~7.8.0", "semver": "^7.1.3", "triple-beam": "^1.3.0", diff --git a/src/package.json b/src/package.json index e0b915e1c..2fe2fdb7f 100644 --- a/src/package.json +++ b/src/package.json @@ -31,7 +31,7 @@ "@angular/common": "^22.0.0", "@angular/core": "^22.0.0", "@angular/platform-browser": "^22.0.0", - "firebase": "^12.18.0", + "firebase": "^12.18.0 || ^13.0.0", "rxjs": "~7.8.0", "firebase-tools": "^14.0.0 || ^15.0.0", "typescript": ">=6.0 <6.1" @@ -41,7 +41,7 @@ "typescript": { "optional": true } }, "dependencies": { - "rxfire": "^6.2.0", + "rxfire": "^6.3.0", "@angular-devkit/architect": ">= 0.2200.0 < 0.2300.0", "tslib": "^2.3.0" }, diff --git a/src/schematics/common.jasmine.ts b/src/schematics/common.jasmine.ts index 2a55eaa64..4fdedd3d1 100644 --- a/src/schematics/common.jasmine.ts +++ b/src/schematics/common.jasmine.ts @@ -1,8 +1,17 @@ -import { readFileSync } from 'fs'; -import { logging } from '@angular-devkit/core'; +import { lstatSync, mkdirSync, mkdtempSync, readFileSync, readdirSync, rmdirSync, unlinkSync, writeFileSync } from 'fs'; +import { tmpdir } from 'os'; +import { join } from 'path'; +import { logging, normalize, virtualFs } from '@angular-devkit/core'; +import { NodeJsSyncHost } from '@angular-devkit/core/node'; import { HostTree, SchematicContext } from '@angular-devkit/schematics'; import { minVersion as semverMinVersion } from 'semver'; -import { alignFirebaseVersion, firebaseVersionRange, pinInstalledPrereleaseVersion } from './common.js'; +import { + alignFirebaseVersion, + firebase13VersionRange, + firebaseVersionRange, + pinInstalledPrereleaseVersion, + supportedFirebaseRange, +} from './common.js'; import 'jasmine'; const context = { logger: new logging.Logger('test') } as unknown as SchematicContext; @@ -135,6 +144,21 @@ const treeWithFirebase = (firebaseVersion?: string, section = 'dependencies') => return tree; }; +const withInstalledFirebase = (tree: HostTree, version: string) => { + tree.create('node_modules/firebase/package.json', JSON.stringify({ name: 'firebase', version })); + return tree; +}; + +/* Not rmSync, which this repo's @types/node predates, and not rmdirSync's recursive option, + * which Node 26 removed. */ +const removeDirectory = (target: string) => { + for (const entry of readdirSync(target)) { + const entryPath = join(target, entry); + if (lstatSync(entryPath).isDirectory()) { removeDirectory(entryPath); } else { unlinkSync(entryPath); } + } + rmdirSync(target); +}; + describe('alignFirebaseVersion', () => { it('rewrites a previous-major range', () => { @@ -214,9 +238,115 @@ describe('alignFirebaseVersion', () => { expect(sectionIn(tree, 'devDependencies').firebase).toBe(firebaseVersionRange); }); + it('leaves a firebase 13 range untouched', () => { + for (const declared of ['^13.0.0', '^13.1.0', '~13.1.0', supportedFirebaseRange]) { + const tree = treeWithFirebase(declared); + expect(alignFirebaseVersion(tree, context)).withContext(declared).toBeFalse(); + expect(dependenciesIn(tree).firebase).withContext(declared).toBe(declared); + } + }); + + it('rewrites an out-of-range entry that starts at firebase 13 to the 13 range, not back to 12', () => { + for (const declared of ['>=13.0.0', '^13.0.0-20260929163754']) { + const tree = treeWithFirebase(declared); + expect(alignFirebaseVersion(tree, context)).withContext(declared).toBeTrue(); + expect(dependenciesIn(tree).firebase).withContext(declared).toBe(firebase13VersionRange); + } + }); + + it('rewrites an out-of-range entry that reaches below firebase 13 to the 12 range when nothing is installed', () => { + for (const declared of ['*', '>=12.18.0']) { + const tree = treeWithFirebase(declared); + expect(alignFirebaseVersion(tree, context)).withContext(declared).toBeTrue(); + expect(dependenciesIn(tree).firebase).withContext(declared).toBe(firebaseVersionRange); + } + }); + + it('adds the range of the firebase major already installed', () => { + for (const [installed, expected] of [['13.1.0', firebase13VersionRange], ['12.19.0', firebaseVersionRange]]) { + const tree = withInstalledFirebase(treeWithFirebase(undefined), installed); + expect(alignFirebaseVersion(tree, context)).withContext(installed).toBeTrue(); + expect(dependenciesIn(tree).firebase).withContext(installed).toBe(expected); + } + }); + + it('rewrites an out-of-range entry to the range of the firebase major already installed', () => { + for (const [installed, expected] of [['13.1.0', firebase13VersionRange], ['12.19.0', firebaseVersionRange]]) { + const tree = withInstalledFirebase(treeWithFirebase('*'), installed); + expect(alignFirebaseVersion(tree, context)).withContext(installed).toBeTrue(); + expect(dependenciesIn(tree).firebase).withContext(installed).toBe(expected); + } + }); + + it('falls back to the declared range when the installed firebase is outside both majors', () => { + for (const [installed, declared, expected] of [ + ['11.10.0', '^11.0.0', firebaseVersionRange], + ['13.0.0-20260929163754', '^13.0.0-20260929163754', firebase13VersionRange], + ]) { + const tree = withInstalledFirebase(treeWithFirebase(declared), installed); + expect(alignFirebaseVersion(tree, context)).withContext(installed).toBeTrue(); + expect(dependenciesIn(tree).firebase).withContext(installed).toBe(expected); + } + }); + + it('ignores an installed copy that the workspace entry excludes', () => { + for (const [installed, declared, expected] of [ + ['13.1.0', '~12.10.0', firebaseVersionRange], + ['12.19.0', '>=13.0.0', firebase13VersionRange], + ]) { + const tree = withInstalledFirebase(treeWithFirebase(declared), installed); + expect(alignFirebaseVersion(tree, context)).withContext(declared).toBeTrue(); + expect(dependenciesIn(tree).firebase).withContext(declared).toBe(expected); + } + }); + + it('finds firebase installed at a monorepo root above the workspace', () => { + const repository = mkdtempSync(join(tmpdir(), 'angularfire-monorepo-')); + try { + const workspace = join(repository, 'apps', 'web'); + mkdirSync(workspace, { recursive: true }); + mkdirSync(join(repository, 'node_modules', 'firebase'), { recursive: true }); + writeFileSync(join(repository, 'node_modules', 'firebase', 'package.json'), + JSON.stringify({ name: 'firebase', version: '13.1.0', exports: { './package.json': './package.json' } })); + writeFileSync(join(workspace, 'package.json'), JSON.stringify({ name: 'web' })); + // The host `ng add` and `ng update` give a schematic: the real filesystem, scoped to the workspace. + const tree = new HostTree(new virtualFs.ScopedHost(new NodeJsSyncHost(), normalize(workspace))); + expect(alignFirebaseVersion(tree, context)).toBeTrue(); + expect(dependenciesIn(tree).firebase).toBe(firebase13VersionRange); + } finally { + removeDirectory(repository); + } + }); + + it('ignores an installed firebase manifest it cannot read', () => { + const tree = treeWithFirebase(undefined); + tree.create('node_modules/firebase/package.json', 'not json'); + expect(alignFirebaseVersion(tree, context)).toBeTrue(); + expect(dependenciesIn(tree).firebase).toBe(firebaseVersionRange); + }); + + it('mentions the supported range when it rewrites an entry', () => { + const infoSpy = spyOn(context.logger, 'info'); + alignFirebaseVersion(treeWithFirebase('^11.0.0'), context); + expect(infoSpy).toHaveBeenCalledWith(jasmine.stringContaining(supportedFirebaseRange)); + }); + it('matches the firebase range the library declares as its peer dependency', () => { const libraryManifest = JSON.parse(readFileSync('src/package.json', 'utf8')); - expect(libraryManifest.peerDependencies.firebase).toBe(firebaseVersionRange); + expect(libraryManifest.peerDependencies.firebase).toBe(supportedFirebaseRange); + }); + + it('builds against the oldest firebase major the peer dependency accepts', () => { + /* tools/build.ts imports wrapped functions by name from the installed firebase, so building + * on a newer major can emit an import that the older major does not export. */ + const rootManifest = JSON.parse(readFileSync('package.json', 'utf8')); + expect(semverMinVersion(rootManifest.dependencies.firebase)?.major) + .toBe(semverMinVersion(supportedFirebaseRange)?.major); + }); + + it('writes a range for exactly the majors the peer dependency accepts', () => { + expect(supportedFirebaseRange.split('||').map(range => range.trim()).sort()) + .toEqual([firebaseVersionRange, firebase13VersionRange].sort()); }); it('is a no-op when run a second time', () => { diff --git a/src/schematics/common.ts b/src/schematics/common.ts index 6df7c8bb1..88ebe9f6d 100644 --- a/src/schematics/common.ts +++ b/src/schematics/common.ts @@ -1,6 +1,11 @@ +import { readFileSync } from 'fs'; +import { dirname, join } from 'path'; +import { asWindowsPath, normalize } from '@angular-devkit/core'; import { SchematicContext, SchematicsException, Tree } from '@angular-devkit/schematics'; import { + gte as semverGte, intersects as semverIntersects, + minVersion as semverMinVersion, prerelease as semverPrerelease, satisfies as semverSatisfies, subset as semverSubset, @@ -73,15 +78,78 @@ export const addDependencies = ( }; /* Must stay identical to `peerDependencies.firebase` in `src/package.json`: if the two drift, the - * alignment below writes a range the peer dependency then rejects. */ + * alignment below keeps or writes a range the peer dependency then rejects. */ +export const supportedFirebaseRange = '^12.18.0 || ^13.0.0'; + export const firebaseVersionRange = '^12.18.0'; +export const firebase13VersionRange = '^13.0.0'; + +/* The directory the tree is rooted at. No public API exposes it, and the setup schematic reads the + * same private field. An in-memory tree has none. */ +const treeRootDirectory = (host: Tree): string | undefined => { + const root: unknown = Reflect.get(Reflect.get(host, '_backend') ?? {}, '_root'); + if (typeof root !== 'string') { return undefined; } + return process.platform.startsWith('win32') ? asWindowsPath(normalize(root)) : root; +}; + +/* Returns the contents of the installed firebase's package.json, or undefined when none is found. */ +const firebaseManifestText = (host: Tree): string | undefined => { + const inTree = host.read('node_modules/firebase/package.json'); + if (inTree) { return inTree.toString('utf-8'); } + /* npm and yarn workspaces install into the monorepo root, above the tree, so walk up from the + * workspace the way Node resolves a package. */ + const treeRoot = treeRootDirectory(host); + for (let directory = treeRoot; directory; directory = dirname(directory) === directory ? undefined : dirname(directory)) { + try { + return readFileSync(join(directory, 'node_modules', 'firebase', 'package.json'), 'utf-8'); + } catch (_) { + continue; + } + } + return undefined; +}; + +/* Returns the version of firebase installed in the workspace, or undefined when none is found or it + * cannot be read. alignFirebaseVersion uses it to write a package.json firebase range that keeps the + * installed major. */ +const installedFirebaseVersion = (host: Tree, context: SchematicContext): string | undefined => { + const manifestText = firebaseManifestText(host); + if (manifestText === undefined) { return undefined; } + let version: unknown; + try { + version = JSON.parse(manifestText).version; + } catch (_) { + version = undefined; + } + const valid = typeof version === 'string' ? semverValid(version) : null; + if (!valid) { + context.logger.debug('The installed firebase package.json has no readable version, so the firebase range comes from package.json alone.'); + } + return valid ?? undefined; +}; + +/* Writes the range of the major already installed. `ng add` installs the missing peer before this + * runs, at the newest version the peer range allows, and writing an older major then makes the + * reinstall leave two @firebase/app copies. An installed copy that the workspace's own entry + * excludes is stale and ignored. Without a usable installed copy, an entry that starts at 13 gets + * the 13 range and anything else the 12 range. */ +const replacementFirebaseRange = (installedVersion: string | undefined, declaredFirebaseVersion?: string) => { + const installedMatchesEntry = installedVersion !== undefined && + (declaredFirebaseVersion === undefined || semverSatisfies(installedVersion, declaredFirebaseVersion)); + const installedRange = !installedMatchesEntry ? undefined : [firebase13VersionRange, firebaseVersionRange].find( + range => semverSatisfies(installedVersion, range), + ); + if (installedRange) { return installedRange; } + const lowestDeclared = declaredFirebaseVersion ? semverMinVersion(declaredFirebaseVersion) : null; + return lowestDeclared && semverGte(lowestDeclared, '13.0.0-0') ? firebase13VersionRange : firebaseVersionRange; +}; /** * Aligns the workspace's `firebase` entry with the range `@angular/fire` requires. * * `firebase` is a peer dependency of `@angular/fire`. Depending on the package manager and its * settings, a workspace range outside it is refused at install time or installs a `firebase` - * the library was not built against, and a workspace that never declared `firebase` gets no + * the library does not support, and a workspace that never declared `firebase` gets no * entry written. Rewriting the entry before the install runs heads both off. * * Returns whether `package.json` was modified, so callers can skip scheduling an install @@ -95,15 +163,17 @@ export const alignFirebaseVersion = ( throw new SchematicsException('Could not locate package.json'); } const packageJson = safeReadJSON('package.json', host); + const installedVersion = installedFirebaseVersion(host, context); const sectionsWithFirebase = ['dependencies', 'devDependencies'].filter( section => typeof packageJson[section]?.firebase === 'string', ); if (sectionsWithFirebase.length === 0) { + const added = replacementFirebaseRange(installedVersion); packageJson.dependencies ??= {}; - packageJson.dependencies.firebase = firebaseVersionRange; - context.logger.info(`Added firebase ${firebaseVersionRange} to your package.json.`); + packageJson.dependencies.firebase = added; + context.logger.info(`Added firebase ${added} to your package.json.`); overwriteIfExists(host, 'package.json', stringifyFormatted(packageJson)); return true; } @@ -113,21 +183,22 @@ export const alignFirebaseVersion = ( const declaredFirebaseVersion = packageJson[section].firebase; let alreadyCompatible: boolean; try { - alreadyCompatible = semverSubset(declaredFirebaseVersion, firebaseVersionRange); + alreadyCompatible = semverSubset(declaredFirebaseVersion, supportedFirebaseRange); } catch (_) { context.logger.warn( `⚠️ The firebase version in your package.json (${declaredFirebaseVersion}) is not a semver ` + - `range, so it was left as-is. Make sure it resolves inside ${firebaseVersionRange}, the ` + - 'range @angular/fire requires. A version outside it either fails to install or installs a firebase that @angular/fire was not built against.' + `range, so it was left as-is. Make sure it resolves inside ${supportedFirebaseRange}, the ` + + 'range @angular/fire supports. A version outside it either fails to install or installs a firebase that @angular/fire does not support.' ); continue; } if (alreadyCompatible) { continue; } - packageJson[section].firebase = firebaseVersionRange; + const replacement = replacementFirebaseRange(installedVersion, declaredFirebaseVersion); + packageJson[section].firebase = replacement; context.logger.info( `Updated the firebase version in your package.json from ${declaredFirebaseVersion} to ` + - `${firebaseVersionRange}, the range @angular/fire requires. A range outside it either fails ` + - 'to install or installs a firebase that @angular/fire was not built against.' + `${replacement}. @angular/fire supports ${supportedFirebaseRange}, and a range outside it either fails ` + + 'to install or installs a firebase that @angular/fire does not support.' ); changed = true; }