Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@ jest.mock('../../notifiers', () => ({
// Mock figma API
const mockSetExplicitVariableModeForCollection = jest.fn();
const mockGetVariableCollectionByIdAsync = jest.fn();
const mockImportVariableByKeyAsync = jest.fn();

const mockCurrentPage = {
name: 'Page 1',
Expand All @@ -36,6 +37,7 @@ global.figma = {
root: mockRoot,
variables: {
getVariableCollectionByIdAsync: mockGetVariableCollectionByIdAsync,
importVariableByKeyAsync: mockImportVariableByKeyAsync,
},
} as any;

Expand Down Expand Up @@ -198,6 +200,128 @@ describe('swapFigmaModes', () => {
consoleSpy.mockRestore();
});

describe('import-based fallback (stale or cross-file collection ID)', () => {
const themeWithVariableRefs = {
id: 'dark',
name: 'Dark',
selectedTokenSets: {},
$figmaCollectionId: 'stale-collection-id',
$figmaModeId: 'stale-mode-id',
$figmaVariableReferences: { 'color.background': 'variable-key-abc' },
};

const fallbackCollection = {
id: 'new-collection-id',
name: 'My Collection',
modes: [
{ modeId: 'new-mode-id', name: 'Dark' },
{ modeId: 'new-mode-light-id', name: 'Light' },
],
};

const importedVariable = {
variableCollectionId: 'new-collection-id',
};

beforeEach(() => {
mockImportVariableByKeyAsync.mockReset();
});

it('should resolve via importVariableByKeyAsync when stored collection ID is stale', async () => {
// Primary lookup fails (stale ID), fallback import succeeds
mockGetVariableCollectionByIdAsync.mockImplementation(async (id: string) => {
if (id === 'new-collection-id') return fallbackCollection;
return null;
});
mockImportVariableByKeyAsync.mockResolvedValue(importedVariable);

await swapFigmaModes({ 'no-group': 'dark' }, [themeWithVariableRefs], UpdateMode.PAGE);

expect(mockImportVariableByKeyAsync).toHaveBeenCalledWith('variable-key-abc');
expect(mockSetExplicitVariableModeForCollection).toHaveBeenCalledWith(fallbackCollection, 'new-mode-id');
expect(notifiers.notifyUI).not.toHaveBeenCalled();
});

it('should report collection-not-found when fallback collection exists but mode name does not match', async () => {
const collectionWithoutDarkMode = {
...fallbackCollection,
modes: [{ modeId: 'new-mode-light-id', name: 'Light' }],
};
mockGetVariableCollectionByIdAsync.mockImplementation(async (id: string) => {
if (id === 'new-collection-id') return collectionWithoutDarkMode;
return null;
});
mockImportVariableByKeyAsync.mockResolvedValue(importedVariable);
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation();

await swapFigmaModes({ 'no-group': 'dark' }, [themeWithVariableRefs], UpdateMode.PAGE);

// Collection was found via fallback, but mode is missing — should report mode-missing error
expect(notifiers.notifyUI).toHaveBeenCalledWith(
expect.stringContaining('mode'),
{ error: true },
);
expect(mockSetExplicitVariableModeForCollection).not.toHaveBeenCalled();

consoleSpy.mockRestore();
});

it('should report collection-not-found when importVariableByKeyAsync throws', async () => {
mockGetVariableCollectionByIdAsync.mockResolvedValue(null);
mockImportVariableByKeyAsync.mockRejectedValue(new Error('Key not found in any library'));
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation();

await swapFigmaModes({ 'no-group': 'dark' }, [themeWithVariableRefs], UpdateMode.PAGE);

expect(notifiers.notifyUI).toHaveBeenCalledWith(
'One of the variable collections linked to this theme no longer exists',
{ error: true },
);
expect(mockSetExplicitVariableModeForCollection).not.toHaveBeenCalled();

consoleSpy.mockRestore();
});

it('should report collection-not-found when $figmaVariableReferences is empty', async () => {
const themeWithNoRefs = { ...themeWithVariableRefs, $figmaVariableReferences: {} };
mockGetVariableCollectionByIdAsync.mockResolvedValue(null);
const consoleSpy = jest.spyOn(console, 'warn').mockImplementation();

await swapFigmaModes({ 'no-group': 'dark' }, [themeWithNoRefs], UpdateMode.PAGE);

expect(mockImportVariableByKeyAsync).not.toHaveBeenCalled();
expect(notifiers.notifyUI).toHaveBeenCalledWith(
'One of the variable collections linked to this theme no longer exists',
{ error: true },
);

consoleSpy.mockRestore();
});

it('should match mode name against truncated name for long theme names', async () => {
const longName = 'A'.repeat(50); // longer than Figma 40-char limit
const truncated = `${'A'.repeat(37)}...`;
const themeWithLongName = {
...themeWithVariableRefs,
name: longName,
};
const collectionWithTruncatedMode = {
...fallbackCollection,
modes: [{ modeId: 'new-mode-id', name: truncated }],
};
mockGetVariableCollectionByIdAsync.mockImplementation(async (id: string) => {
if (id === 'new-collection-id') return collectionWithTruncatedMode;
return null;
});
mockImportVariableByKeyAsync.mockResolvedValue(importedVariable);

await swapFigmaModes({ 'no-group': 'dark' }, [themeWithLongName], UpdateMode.PAGE);

expect(mockSetExplicitVariableModeForCollection).toHaveBeenCalledWith(collectionWithTruncatedMode, 'new-mode-id');
expect(notifiers.notifyUI).not.toHaveBeenCalled();
});
});

describe('multi-dimensional themes', () => {
const mockBrandCollection = {
id: 'collection-brand',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { UpdateMode } from '@/constants/UpdateMode';
import { ThemeObjectsList, ThemeObject } from '@/types';
import { truncateModeName } from '@/utils/truncateName';
import { notifyUI, notifyException } from '../notifiers';

// Type predicate to check for Figma theme with collection and mode IDs
Expand Down Expand Up @@ -54,25 +55,51 @@ export async function swapFigmaModes(activeTheme: Record<string, string>, themes
const { $figmaCollectionId: collectionId, $figmaModeId: modeId } = themeObject;

// Validate that the collection exists and contains the mode
const collection = await figma.variables.getVariableCollectionByIdAsync(collectionId);
if (!collection) {
let resolvedCollection = await figma.variables.getVariableCollectionByIdAsync(collectionId);
let resolvedModeId = modeId;

if (!resolvedCollection) {
// Fallback: the stored ID is from a different file (e.g. a published library used in a consumer file,
// or a collection that was deleted and recreated). Try to resolve via a variable key from
// $figmaVariableReferences — variable keys are stable across files.
const variableKey = Object.values(themeObject.$figmaVariableReferences ?? {})[0];
if (variableKey) {
try {
const importedVariable = await figma.variables.importVariableByKeyAsync(variableKey);
if (importedVariable) {
Comment on lines +61 to +69
const fallbackCollection = await figma.variables.getVariableCollectionByIdAsync(importedVariable.variableCollectionId);
if (fallbackCollection) {
resolvedCollection = fallbackCollection;
const matchingMode = fallbackCollection.modes.find((mode) => mode.name === truncateModeName(themeObject.name));
if (matchingMode) {
resolvedModeId = matchingMode.modeId;
}
}
Comment thread
akshay-gupta7 marked this conversation as resolved.
}
} catch (e) {
// importVariableByKeyAsync throws if the key cannot be found in any subscribed library
}
}
}

if (!resolvedCollection) {
// eslint-disable-next-line no-console
console.warn(`Variable collection with ID ${collectionId} no longer exists. Skipping this theme dimension.`);
notifyUI('One of the variable collections linked to this theme no longer exists', { error: true });
// eslint-disable-next-line no-continue
continue;
}

const modeExists = collection.modes.some((mode) => mode.modeId === modeId);
const modeExists = resolvedCollection.modes.some((mode) => mode.modeId === resolvedModeId);
if (!modeExists) {
// eslint-disable-next-line no-console
console.warn(`Mode ${modeId} no longer exists in collection ${collection.name}. Skipping this theme dimension.`);
notifyUI(`One of the modes linked to this theme no longer exists in collection "${collection.name}"`, { error: true });
console.warn(`Mode ${resolvedModeId} no longer exists in collection ${resolvedCollection.name}. Skipping this theme dimension.`);
notifyUI(`One of the modes linked to this theme no longer exists in collection "${resolvedCollection.name}"`, { error: true });
// eslint-disable-next-line no-continue
continue;
}

validCollectionModePairs.push({ collection, modeId });
validCollectionModePairs.push({ collection: resolvedCollection, modeId: resolvedModeId });
}

if (validCollectionModePairs.length === 0) {
Expand Down
Loading