Skip to content

Commit e999a7a

Browse files
author
alafemina_sfemu
committed
Sync from monorepo
Template version: 1.1.0-dev Uses bundled workspace packages (not yet on NPM) Synced by: alafemina_sfemu Monorepo SHA: efdee117b21a09a2e49a863d908d8d8a790cf4f8 Latest change: efdee117b - Merge pull request #1901 from commerce-emu/W-22727076-validate-uitarget-ids
1 parent d355960 commit e999a7a

15 files changed

Lines changed: 367 additions & 28 deletions

File tree

packages/storefront-next-dev/dist/index.d.ts.map

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/storefront-next-dev/dist/index.js

Lines changed: 45 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/storefront-next-dev/dist/index.js.map

Lines changed: 1 addition & 1 deletion
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

packages/storefront-next-dev/src/extensibility/target-utils.test.ts

Lines changed: 179 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,7 +16,7 @@
1616
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
1717
import { join } from 'path';
1818
import fs from 'fs-extra';
19-
import { buildTargetRegistry, transformTargets } from './target-utils';
19+
import { buildTargetRegistry, transformTargets, collectUITargetIds, validateTargetRegistry } from './target-utils';
2020
import { logger } from '../utils/logger';
2121

2222
describe('target-utils', () => {
@@ -630,4 +630,182 @@ describe('target-utils', () => {
630630
expect(result).not.toContain('UITargetProviders');
631631
});
632632
});
633+
634+
describe('collectUITargetIds', () => {
635+
const testDir = join(__dirname, '__test-collect-targets__');
636+
637+
beforeEach(() => {
638+
fs.ensureDirSync(testDir);
639+
});
640+
641+
afterEach(() => {
642+
fs.removeSync(testDir);
643+
});
644+
645+
it('should collect targetIds from tsx files', () => {
646+
fs.ensureDirSync(join(testDir, 'components'));
647+
fs.writeFileSync(
648+
join(testDir, 'components', 'page.tsx'),
649+
`<UITarget targetId="sfcc.header.before.cart" />\n<UITarget targetId="sfcc.footer.start" />`
650+
);
651+
652+
const ids = collectUITargetIds(testDir);
653+
expect(ids).toContain('sfcc.header.before.cart');
654+
expect(ids).toContain('sfcc.footer.start');
655+
expect(ids.size).toBe(2);
656+
});
657+
658+
it('should exclude the extensions directory', () => {
659+
fs.ensureDirSync(join(testDir, 'extensions', 'my-ext'));
660+
fs.writeFileSync(
661+
join(testDir, 'extensions', 'my-ext', 'comp.tsx'),
662+
`<UITarget targetId="should.not.be.found" />`
663+
);
664+
fs.ensureDirSync(join(testDir, 'components'));
665+
fs.writeFileSync(join(testDir, 'components', 'page.tsx'), `<UITarget targetId="valid.target" />`);
666+
667+
const ids = collectUITargetIds(testDir);
668+
expect(ids).not.toContain('should.not.be.found');
669+
expect(ids).toContain('valid.target');
670+
});
671+
672+
it('should exclude ui-target-dev-mode and ui-target-smoke-test directories', () => {
673+
fs.ensureDirSync(join(testDir, 'ui-target-dev-mode'));
674+
fs.writeFileSync(
675+
join(testDir, 'ui-target-dev-mode', 'tool.tsx'),
676+
`<UITarget targetId="dev.only.target" />`
677+
);
678+
fs.ensureDirSync(join(testDir, 'ui-target-smoke-test'));
679+
fs.writeFileSync(
680+
join(testDir, 'ui-target-smoke-test', 'test.tsx'),
681+
`<UITarget targetId="smoke.test.target" />`
682+
);
683+
684+
const ids = collectUITargetIds(testDir);
685+
expect(ids).not.toContain('dev.only.target');
686+
expect(ids).not.toContain('smoke.test.target');
687+
});
688+
689+
it('should return empty set when no targets found', () => {
690+
fs.writeFileSync(join(testDir, 'page.tsx'), `export default function Page() { return <div />; }`);
691+
692+
const ids = collectUITargetIds(testDir);
693+
expect(ids.size).toBe(0);
694+
});
695+
696+
it('should match single-quoted targetId attributes', () => {
697+
fs.ensureDirSync(join(testDir, 'components'));
698+
fs.writeFileSync(
699+
join(testDir, 'components', 'page.tsx'),
700+
`<UITarget targetId='sfcc.header.single.quote' />`
701+
);
702+
703+
const ids = collectUITargetIds(testDir);
704+
expect(ids).toContain('sfcc.header.single.quote');
705+
});
706+
707+
it('should not match targetId on non-UITarget components', () => {
708+
fs.ensureDirSync(join(testDir, 'components'));
709+
fs.writeFileSync(
710+
join(testDir, 'components', 'page.tsx'),
711+
`<SomeOther targetId="not.a.real.target" />\n<UITarget targetId="real.target" />`
712+
);
713+
714+
const ids = collectUITargetIds(testDir);
715+
expect(ids).not.toContain('not.a.real.target');
716+
expect(ids).toContain('real.target');
717+
expect(ids.size).toBe(1);
718+
});
719+
720+
it('should exclude test files', () => {
721+
fs.ensureDirSync(join(testDir, 'components'));
722+
fs.writeFileSync(join(testDir, 'components', 'page.tsx'), `<UITarget targetId="real.target" />`);
723+
fs.writeFileSync(join(testDir, 'components', 'page.test.tsx'), `<UITarget targetId="test.only.target" />`);
724+
725+
const ids = collectUITargetIds(testDir);
726+
expect(ids).toContain('real.target');
727+
expect(ids).not.toContain('test.only.target');
728+
expect(ids.size).toBe(1);
729+
});
730+
});
731+
732+
describe('validateTargetRegistry', () => {
733+
it('should return empty array when all targetIds are declared', () => {
734+
const registry = {
735+
'sfcc.header.before.cart': [
736+
{
737+
targetId: 'sfcc.header.before.cart',
738+
path: 'extensions/foo/comp.tsx',
739+
namespace: 'Foo',
740+
componentName: 'Foo_Comp',
741+
order: 0,
742+
},
743+
],
744+
};
745+
const declared = new Set(['sfcc.header.before.cart', 'sfcc.footer.start']);
746+
747+
const orphaned = validateTargetRegistry(registry, declared);
748+
expect(orphaned).toHaveLength(0);
749+
});
750+
751+
it('should return orphaned entries for undeclared targetIds', () => {
752+
const registry = {
753+
'sfcc.header.before.cart': [
754+
{
755+
targetId: 'sfcc.header.before.cart',
756+
path: 'extensions/foo/comp.tsx',
757+
namespace: 'Foo',
758+
componentName: 'Foo_Comp',
759+
order: 0,
760+
},
761+
],
762+
'sfcc.nonexistent.target': [
763+
{
764+
targetId: 'sfcc.nonexistent.target',
765+
path: 'extensions/bar/widget.tsx',
766+
namespace: 'Bar',
767+
componentName: 'Bar_Widget',
768+
order: 0,
769+
},
770+
],
771+
};
772+
const declared = new Set(['sfcc.header.before.cart']);
773+
774+
const orphaned = validateTargetRegistry(registry, declared);
775+
expect(orphaned).toHaveLength(1);
776+
expect(orphaned[0].targetId).toBe('sfcc.nonexistent.target');
777+
expect(orphaned[0].extension).toBe('Bar');
778+
expect(orphaned[0].componentPath).toBe('extensions/bar/widget.tsx');
779+
});
780+
781+
it('should report all components for a single orphaned targetId', () => {
782+
const registry = {
783+
'missing.target': [
784+
{
785+
targetId: 'missing.target',
786+
path: 'extensions/a/comp.tsx',
787+
namespace: 'A',
788+
componentName: 'A_Comp',
789+
order: 0,
790+
},
791+
{
792+
targetId: 'missing.target',
793+
path: 'extensions/b/comp.tsx',
794+
namespace: 'B',
795+
componentName: 'B_Comp',
796+
order: 1,
797+
},
798+
],
799+
};
800+
const declared = new Set<string>();
801+
802+
const orphaned = validateTargetRegistry(registry, declared);
803+
expect(orphaned).toHaveLength(2);
804+
});
805+
806+
it('should return empty array for empty registry', () => {
807+
const orphaned = validateTargetRegistry({}, new Set(['some.target']));
808+
expect(orphaned).toHaveLength(0);
809+
});
810+
});
633811
});

packages/storefront-next-dev/src/extensibility/target-utils.ts

Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -465,3 +465,68 @@ export function buildTargetRegistry(
465465

466466
return { componentRegistry, contextProviders, actionHookRegistry };
467467
}
468+
469+
const TARGET_ID_PATTERN = /<UITarget[\s][^>]*targetId=["']([^"']+)["']/g;
470+
const EXCLUDED_DIRS = new Set(['ui-target-dev-mode', 'ui-target-smoke-test']);
471+
472+
/**
473+
* Recursively collect all UITarget IDs declared in template source files.
474+
* Excludes extension directories so only "real" UITarget placements are counted.
475+
*/
476+
export function collectUITargetIds(sourceDir: string): Set<string> {
477+
const result = new Set<string>();
478+
479+
function walk(dir: string) {
480+
const entries = fs.readdirSync(dir, { withFileTypes: true });
481+
for (const entry of entries) {
482+
const fullPath = path.join(dir, entry.name);
483+
if (entry.isDirectory()) {
484+
if (entry.name === 'extensions' || EXCLUDED_DIRS.has(entry.name)) continue;
485+
walk(fullPath);
486+
} else if (
487+
entry.isFile() &&
488+
/\.(tsx?|jsx?)$/.test(entry.name) &&
489+
!/\.test\.(tsx?|jsx?)$/.test(entry.name)
490+
) {
491+
const content = fs.readFileSync(fullPath, 'utf-8');
492+
TARGET_ID_PATTERN.lastIndex = 0;
493+
let match;
494+
while ((match = TARGET_ID_PATTERN.exec(content)) !== null) {
495+
result.add(match[1]);
496+
}
497+
}
498+
}
499+
}
500+
501+
walk(sourceDir);
502+
return result;
503+
}
504+
505+
export interface OrphanedTarget {
506+
targetId: string;
507+
extension: string;
508+
componentPath: string;
509+
}
510+
511+
/**
512+
* Validate that all targetIds in the component registry correspond to
513+
* UITarget declarations in the template source. Returns orphaned entries.
514+
*/
515+
export function validateTargetRegistry(
516+
componentRegistry: TargetComponentRegistry,
517+
declaredTargetIds: Set<string>
518+
): OrphanedTarget[] {
519+
const orphaned: OrphanedTarget[] = [];
520+
for (const targetId in componentRegistry) {
521+
if (!declaredTargetIds.has(targetId)) {
522+
for (const entry of componentRegistry[targetId]) {
523+
orphaned.push({
524+
targetId,
525+
extension: entry.namespace,
526+
componentPath: entry.path,
527+
});
528+
}
529+
}
530+
}
531+
return orphaned;
532+
}

packages/storefront-next-dev/src/plugins/transformTarget.test.ts

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -31,10 +31,14 @@ const viteConfig = {
3131
vi.mock('../extensibility/target-utils', () => ({
3232
buildTargetRegistry: vi.fn(),
3333
transformTargets: vi.fn(),
34+
collectUITargetIds: vi.fn(),
35+
validateTargetRegistry: vi.fn(),
3436
}));
3537

3638
import {
3739
buildTargetRegistry,
40+
collectUITargetIds,
41+
validateTargetRegistry,
3842
type TargetComponentRegistry,
3943
type TargetContextProviderConfig,
4044
transformTargets,
@@ -75,6 +79,8 @@ describe('transformTargetPlaceholderPlugin', () => {
7579
actionHookRegistry: {},
7680
}));
7781
transformTargetsMock = vi.mocked(transformTargets);
82+
vi.mocked(collectUITargetIds).mockReturnValue(new Set(['test.target']));
83+
vi.mocked(validateTargetRegistry).mockReturnValue([]);
7884
vi.spyOn(process, 'cwd').mockReturnValue('/project');
7985

8086
vitePlugin = transformTargetPlaceholderPlugin();
@@ -166,4 +172,32 @@ describe('transformTargetPlaceholderPlugin', () => {
166172
);
167173
errorSpy.mockRestore();
168174
});
175+
176+
it('should throw when extensions target non-existent UITarget IDs', () => {
177+
vi.mocked(validateTargetRegistry).mockReturnValue([
178+
{
179+
targetId: 'sfcc.nonexistent.target',
180+
extension: 'MyExt',
181+
componentPath: 'extensions/my-ext/comp.tsx',
182+
},
183+
]);
184+
185+
expect(() => vitePlugin.buildStart()).toThrow(
186+
/1 extension component\(s\) target UITarget IDs that do not exist/
187+
);
188+
expect(() => vitePlugin.buildStart()).toThrow(/sfcc\.nonexistent\.target/);
189+
});
190+
191+
it('should not throw when all extension targets are valid', () => {
192+
vi.mocked(validateTargetRegistry).mockReturnValue([]);
193+
194+
expect(() => vitePlugin.buildStart()).not.toThrow();
195+
});
196+
197+
it('should call collectUITargetIds with sourceDir and validateTargetRegistry with registry', () => {
198+
vitePlugin.buildStart();
199+
200+
expect(collectUITargetIds).toHaveBeenCalledWith('src');
201+
expect(validateTargetRegistry).toHaveBeenCalledWith(mockComponentRegistry, new Set(['test.target']));
202+
});
169203
});

0 commit comments

Comments
 (0)