import { afterEach, describe, expect, it, vi } from 'vitest'; import { combineOffers, lastActiveAt, mergeApps, pickLangString, scopeNsids, } from '../src/lib/format.js'; describe('lastActiveAt', () => { it('returns the newest session timestamp', () => { expect( lastActiveAt({ sessions: [ { lastAccessedAt: '2026-07-01T00:00:00.000Z' }, { lastAccessedAt: '2026-08-01T00:00:00.000Z' }, { lastAccessedAt: '2026-06-01T00:00:00.000Z' }, ], }), ).toBe('2026-08-01T00:00:00.000Z'); }); it('returns the latest record write for a data-only app', () => { expect( lastActiveAt({ collections: [ { lastRecordAt: Date.parse('2026-05-01T00:00:00.000Z') }, { lastRecordAt: Date.parse('2026-07-15T00:00:00.000Z') }, { lastRecordAt: null }, ], }), ).toBe('2026-07-15T00:00:00.000Z'); }); it('takes the newest of a session and a record write', () => { expect( lastActiveAt({ sessions: [{ lastAccessedAt: '2026-07-01T00:00:00.000Z' }], collections: [{ lastRecordAt: Date.parse('2026-08-10T00:00:00.000Z') }], }), ).toBe('2026-08-10T00:00:00.000Z'); }); it('takes a space write over an older session and repo write', () => { expect( lastActiveAt({ sessions: [{ lastAccessedAt: '2026-07-01T00:00:00.000Z' }], collections: [{ lastRecordAt: Date.parse('2026-07-20T00:00:00.000Z') }], spaces: [ { collections: [ { lastRecordAt: Date.parse('2026-08-09T00:00:00.000Z') }, { lastRecordAt: null }, ], }, ], }), ).toBe('2026-08-09T00:00:00.000Z'); }); it('returns the latest space write for an app that stores nothing else', () => { expect( lastActiveAt({ spaces: [ { collections: [ { lastRecordAt: Date.parse('2026-06-01T00:00:00.000Z') }, ], }, { collections: [ { lastRecordAt: Date.parse('2026-08-02T00:00:00.000Z') }, ], }, ], }), ).toBe('2026-08-02T00:00:00.000Z'); }); it('returns null when no session, record or space time is known', () => { expect(lastActiveAt({ sessions: [] })).toBeNull(); expect(lastActiveAt({})).toBeNull(); expect(lastActiveAt({ collections: [{ lastRecordAt: null }] })).toBeNull(); expect(lastActiveAt({ spaces: [{ collections: [] }] })).toBeNull(); }); }); describe('mergeApps ordering', () => { // Collection authority domains that match their app's client host, so a // record bucket and its OAuth session fold into one row. const collections = [ { name: 'app.bsky.feed.post', count: 100 }, // -> bsky.app { name: 'social.grain.photo', count: 5 }, // -> grain.social ]; it('puts the most recently accessed session first, regardless of record count', () => { const oauthApps = [ { clientId: 'https://bsky.app/client-metadata.json', lastAccessedAt: '2026-07-01T00:00:00.000Z', }, { clientId: 'https://grain.social/client-metadata.json', lastAccessedAt: '2026-08-01T00:00:00.000Z', }, ]; const apps = mergeApps(collections, oauthApps); // grain.social has far fewer records but was accessed more recently. expect(apps.map((a) => a.domain)).toEqual(['grain.social', 'bsky.app']); }); it('falls back to record count for apps that have no live session', () => { // No sessions at all: pure data apps sort by record count, most first. const apps = mergeApps(collections, []); expect(apps.map((a) => a.domain)).toEqual(['bsky.app', 'grain.social']); }); it('orders data-only apps by their latest record write, not just count', () => { const dated = [ // Many records, but the newest is old. { name: 'app.bsky.feed.post', count: 100, lastRecordAt: Date.parse('2026-01-01T00:00:00.000Z'), }, // Few records, but written recently. { name: 'social.grain.photo', count: 5, lastRecordAt: Date.parse('2026-08-01T00:00:00.000Z'), }, ]; const apps = mergeApps(dated, []); expect(apps.map((a) => a.domain)).toEqual(['grain.social', 'bsky.app']); }); it('sorts apps with sessions ahead of data-only apps', () => { const oauthApps = [ { // tangled.org holds a session but stores no records here. clientId: 'https://tangled.org/client-metadata.json', lastAccessedAt: '2026-08-01T00:00:00.000Z', }, ]; const apps = mergeApps(collections, oauthApps); // The app with recent activity leads even the 100-record bsky.app. expect(apps[0].domain).toBe('tangled.org'); }); it('orders an app by a space write newer than anything in the repo', () => { const oauthApps = [ { clientId: 'https://bsky.app/client-metadata.json', lastAccessedAt: '2026-08-01T00:00:00.000Z', }, { clientId: 'https://grain.social/client-metadata.json', lastAccessedAt: '2026-07-01T00:00:00.000Z', }, ]; const spaces = [ { spaceType: 'social.grain.space', collections: [ { collection: 'social.grain.photo', lastRecordAt: Date.parse('2026-08-09T00:00:00.000Z'), }, ], }, ]; const apps = mergeApps(collections, oauthApps, {}, spaces); expect(apps.map((a) => a.domain)).toEqual(['grain.social', 'bsky.app']); expect(apps[0].spaces).toHaveLength(1); }); it('gives a space to the app whose collections it holds', () => { const spaces = [ { spaceType: 'community.lexicon.space', collections: [{ collection: 'social.grain.photo', lastRecordAt: null }], }, ]; const apps = mergeApps(collections, [], {}, spaces); const grain = apps.find((a) => a.domain === 'grain.social'); expect(grain?.spaces).toHaveLength(1); expect(apps.find((a) => a.domain === 'bsky.app')?.spaces).toEqual([]); }); it('gives a space to the row that absorbed the domain it belongs to', () => { const spaces = [ { spaceType: 'sh.tangled.space', collections: [ { collection: 'sh.tangled.repo', lastRecordAt: Date.parse('2026-08-09T00:00:00.000Z'), }, ], }, ]; const apps = mergeApps( [{ name: 'sh.tangled.repo', count: 40 }], [ { clientId: 'https://tangled.org/client-metadata.json', lastAccessedAt: '2026-01-01T00:00:00.000Z', }, ], { 'tangled.sh': 'tangled.org' }, spaces, ); expect(apps).toHaveLength(1); expect(apps[0].domain).toBe('tangled.org'); expect(apps[0].folded).toEqual(['tangled.sh']); expect(apps[0].spaces).toHaveLength(1); expect(lastActiveAt(apps[0])).toBe('2026-08-09T00:00:00.000Z'); }); }); describe('scopeNsids', () => { it('reads the NSIDs granular repo and rpc grants name', () => { expect( scopeNsids( 'atproto repo:sh.tangled.repo?action=create rpc:sh.tangled.ci.triggerPipeline?aud=* blob:*/*', ), ).toEqual(['sh.tangled.repo', 'sh.tangled.ci.triggerPipeline']); }); it('trims a wildcard tail down to the prefix that still names an authority', () => { expect(scopeNsids('repo:sh.tangled.*')).toEqual(['sh.tangled']); }); it('names nothing for a generic grant or a bare wildcard', () => { expect(scopeNsids('atproto transition:generic')).toEqual([]); expect(scopeNsids('repo:*')).toEqual([]); }); it('reads the query forms the scope grammar also allows', () => { expect( scopeNsids( 'repo?collection=sh.tangled.repo&action=create rpc?lxm=sh.tangled.knot.addMember&aud=*', ), ).toEqual(['sh.tangled.repo', 'sh.tangled.knot.addMember']); }); }); describe('combineOffers', () => { // The rebrand signature: tangled moved its sign-in to a new TLD while its // records keep the old NSID authority. const rebrand = [ { domain: 'tangled.org', count: 0, sessions: [{ scope: 'atproto repo:sh.tangled.repo?action=create' }], }, { domain: 'tangled.sh', count: 40, sessions: [] }, ]; it('offers to fold the record row into the session row after a rebrand', () => { expect(combineOffers(rebrand)).toEqual([ { from: 'tangled.sh', to: 'tangled.org' }, ]); }); it('still offers when the session grant is generic', () => { const generic = [ { domain: 'tangled.org', count: 0, sessions: [{ scope: 'atproto transition:generic' }], }, rebrand[1], ]; expect(combineOffers(generic)).toEqual([ { from: 'tangled.sh', to: 'tangled.org' }, ]); }); it('never pairs rows whose names differ, even when scopes would cover them', () => { // A third-party client writing another app's lexicons is not that app. const alt = [ { domain: 'deer.social', count: 0, sessions: [{ scope: 'atproto repo:app.bsky.feed.post' }], }, { domain: 'bsky.app', count: 100, sessions: [] }, ]; expect(combineOffers(alt)).toEqual([]); }); it('drops a pair whose granular scopes never touch the record authority', () => { // A look-alike domain that only ever asked for its own lexicons. const squat = [ { domain: 'tangled.click', count: 0, sessions: [{ scope: 'atproto repo:click.tangled.note' }], }, rebrand[1], ]; expect(combineOffers(squat)).toEqual([]); }); it('stays quiet once the pair is combined or turned down', () => { expect( combineOffers(rebrand, { aliases: { 'tangled.sh': 'tangled.org' } }), ).toEqual([]); expect( combineOffers(rebrand, { dismissedPairs: ['tangled.sh>tangled.org'] }), ).toEqual([]); }); it('folds a subdomain client into the apex the records are named by', () => { // The app is known as currents.is; api.currents.is only hosts the // sign-in, so the apex keeps the row. const subdomain = [ { domain: 'api.currents.is', count: 0, sessions: [{ scope: 'atproto repo:is.currents.note' }], }, { domain: 'currents.is', count: 12, sessions: [] }, ]; expect(combineOffers(subdomain)).toEqual([ { from: 'api.currents.is', to: 'currents.is' }, ]); }); it('keys dismissals by the direction the offer would fold', () => { const subdomain = [ { domain: 'api.currents.is', count: 0, sessions: [{ scope: 'atproto' }] }, { domain: 'currents.is', count: 12, sessions: [] }, ]; expect( combineOffers(subdomain, { dismissedPairs: ['api.currents.is>currents.is'], }), ).toEqual([]); }); it('needs the complement: two rows that both hold records never pair', () => { const both = [ { domain: 'tangled.org', count: 3, sessions: [{ scope: 'atproto repo:sh.tangled.repo' }], }, rebrand[1], ]; expect(combineOffers(both)).toEqual([]); }); }); describe('mergeApps aliases', () => { const collections = [ { name: 'sh.tangled.repo', count: 40, lastRecordAt: Date.parse('2026-08-01T00:00:00.000Z'), }, { name: 'app.bsky.feed.post', count: 100 }, ]; const oauthApps = [ { clientId: 'https://tangled.org/client-metadata.json', lastAccessedAt: '2026-08-05T00:00:00.000Z', }, ]; it('folds an aliased record row into the row that absorbed it', () => { const apps = mergeApps(collections, oauthApps, { 'tangled.sh': 'tangled.org', }); expect(apps.map((a) => a.domain)).toEqual(['tangled.org', 'bsky.app']); const tangled = apps[0]; expect(tangled.count).toBe(40); expect(tangled.collections.map((c) => c.name)).toEqual(['sh.tangled.repo']); expect(tangled.sessions).toHaveLength(1); expect(tangled.folded).toEqual(['tangled.sh']); }); it('keeps a folded row alive when its session is gone', () => { const apps = mergeApps(collections, [], { 'tangled.sh': 'tangled.org' }); const tangled = apps.find((a) => a.domain === 'tangled.org'); expect(tangled?.count).toBe(40); expect(tangled?.sessions).toEqual([]); }); it('folds a session-only client row into the record row it signs in for', () => { const apps = mergeApps( [{ name: 'is.currents.note', count: 12 }], [ { clientId: 'https://api.currents.is/oauth/client-metadata.json', lastAccessedAt: '2026-08-05T00:00:00.000Z', }, ], { 'api.currents.is': 'currents.is' }, ); expect(apps).toHaveLength(1); expect(apps[0].domain).toBe('currents.is'); expect(apps[0].count).toBe(12); expect(apps[0].sessions).toHaveLength(1); expect(apps[0].folded).toEqual(['api.currents.is']); }); it('ignores an alias whose source row does not exist', () => { const apps = mergeApps(collections, oauthApps, { 'gone.example': 'tangled.org', }); expect( apps.find((a) => a.domain === 'tangled.org')?.folded, ).toBeUndefined(); expect(apps.find((a) => a.domain === 'tangled.sh')).toBeDefined(); }); }); describe('pickLangString', () => { /** @param {string[]} languages */ const speak = (languages) => { vi.stubGlobal('navigator', { languages }); }; afterEach(() => { vi.unstubAllGlobals(); }); it('keeps the English base for an English-first reader', () => { // The untagged base is English by convention, so a French translation // later in the reader's language list must not beat it. speak(['en-US', 'fr']); expect(pickLangString('Full access', { fr: 'Accès complet' })).toBe( 'Full access', ); }); it("prefers a translation in the reader's first language", () => { speak(['fr-BE', 'en']); expect(pickLangString('Full access', { fr: 'Accès complet' })).toBe( 'Accès complet', ); }); it('reaches across regions of the same language', () => { speak(['pt-PT']); expect(pickLangString('Full access', { 'pt-BR': 'Acesso total' })).toBe( 'Acesso total', ); }); it('lets an explicit en tag beat the base', () => { speak(['en-US']); expect(pickLangString('base', { en: 'tagged English' })).toBe( 'tagged English', ); }); it('falls back to the base when nothing matches', () => { speak(['ja']); expect(pickLangString('Full access', { fr: 'Accès complet' })).toBe( 'Full access', ); }); }); describe('scopeDisplay', () => { it('parses a granted scope string into the consent display shape', async () => { const { scopeDisplay } = await import('../src/lib/scope-display.js'); const perms = scopeDisplay( 'atproto repo:social.grain.photo repo:app.bsky.feed.post?action=create rpc:social.grain.actor.getProfile?aud=did:web:api.grain.social#grain blob:image/* account:email', ); expect(perms.hasAtproto).toBe(true); expect(perms.hasEmail).toBe(true); expect(perms.repo).toEqual([ { collection: 'social.grain.photo', create: true, update: true, delete: true, }, { collection: 'app.bsky.feed.post', create: true, update: false, delete: false, }, ]); expect(perms.rpc).toEqual([ { aud: 'did:web:api.grain.social#grain', lxms: ['social.grain.actor.getProfile'], }, ]); expect(perms.blobs).toEqual(['image/*']); }); it('renders a bare atproto grant as identity-only', async () => { const { scopeDisplay } = await import('../src/lib/scope-display.js'); const perms = scopeDisplay('atproto'); expect(perms.repo).toEqual([]); expect(perms.hasAtproto).toBe(true); }); });