Skip to content
Merged
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
4 changes: 2 additions & 2 deletions package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@civitas-cerebrum/element-repository",
"version": "0.0.9",
"version": "0.1.0",
"description": "A lightweight, robust package that decouples your UI selectors from your test code. By externalizing locators into a central JSON repository, you make your test automation framework cleaner, easier to maintain, and accessible to non-developers.",
"main": "dist/index.js",
"types": "dist/index.d.ts",
Expand Down
52 changes: 43 additions & 9 deletions src/repo/ElementRepository.ts
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,11 @@ export class ElementRepository {
}

/**
* Filters an element list and returns the first element that contains the specified text.
* Filters an element list and returns the first element matching the specified text.
*
* Matching strategy: first attempts an exact match (trimmed), then falls back
* to a contains match if no exact match is found.
*
* @param page The page/driver instance.
* @param pageName The name of the page block in the JSON repository.
* @param elementName The specific element name to look up.
Expand All @@ -143,10 +147,19 @@ export class ElementRepository {
*/
public async getByText(page: any, pageName: string, elementName: string, desiredText: string, strict: boolean = false): Promise<Element | null> {
const allElements = await this.getAll(page, pageName, elementName);

// First pass: exact match
for (const element of allElements) {
const text = await element.textContent();
if (text?.trim() === desiredText) return element;
}

// Second pass: contains match
for (const element of allElements) {
const text = await element.textContent();
if (text?.trim().includes(desiredText)) return element;
}

const msg = `Element '${elementName}' on '${pageName}' with text "${desiredText}" not found.`;
if (strict) throw new Error(msg);
console.warn(msg);
Expand All @@ -155,13 +168,18 @@ export class ElementRepository {

/**
* Filters elements by a specific HTML attribute value.
*
* Matching strategy: when `exact` is not specified, first attempts an exact
* match, then falls back to a contains match. When `exact` is explicitly set,
* only that matching mode is used.
*
* @param page The page/driver instance.
* @param pageName The name of the page block in the JSON repository.
* @param elementName The specific element name to look up.
* @param attribute The HTML attribute name to filter by.
* @param value The attribute value to match against.
* @param options Optional configuration.
* @param options.exact If true (default), requires an exact attribute match. If false, matches when the attribute contains the value.
* @param options.exact If true, requires an exact attribute match. If false, matches when the attribute contains the value. If omitted, tries exact first then falls back to contains.
* @param options.strict If true, throws an error when no matching element is found. Defaults to false.
* @returns A promise that resolves to the matched Element, or null if not found.
*/
Expand All @@ -173,18 +191,34 @@ export class ElementRepository {
value: string,
options: { exact?: boolean; strict?: boolean } = {}
): Promise<Element | null> {
const { exact = true, strict = false } = options;
const { exact, strict = false } = options;
const allElements = await this.getAll(page, pageName, elementName);

for (const element of allElements) {
const attrValue = await element.getAttribute(attribute);
if (attrValue === null) continue;
// When exact is explicitly set, use only that matching mode
if (exact !== undefined) {
for (const element of allElements) {
const attrValue = await element.getAttribute(attribute);
if (attrValue === null) continue;

const matches = exact ? attrValue === value : attrValue.includes(value);
if (matches) return element;
}
} else {
// Default: try exact match first, then fall back to contains
for (const element of allElements) {
const attrValue = await element.getAttribute(attribute);
if (attrValue === null) continue;
if (attrValue === value) return element;
}

const matches = exact ? attrValue === value : attrValue.includes(value);
if (matches) return element;
for (const element of allElements) {
const attrValue = await element.getAttribute(attribute);
if (attrValue === null) continue;
if (attrValue.includes(value)) return element;
}
}

const matchType = exact ? 'equal to' : 'containing';
const matchType = exact === true ? 'equal to' : exact === false ? 'containing' : 'matching';
const msg = `Element '${elementName}' on '${pageName}' with attribute [${attribute}] ${matchType} "${value}" not found.`;
if (strict) throw new Error(msg);
console.warn(msg);
Expand Down
28 changes: 27 additions & 1 deletion tests/element-repository.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -360,7 +360,7 @@ test.describe('getByAttribute', () => {
const repo = new ElementRepository(webMockData);
await expect(
repo.getByAttribute(mockPage, 'TestPage', 'button', 'class', 'nonexistent', { strict: true })
).rejects.toThrow('Element \'button\' on \'TestPage\' with attribute [class] equal to "nonexistent" not found.');
).rejects.toThrow('Element \'button\' on \'TestPage\' with attribute [class] matching "nonexistent" not found.');
});

test('throws with partial-match wording when exact=false and strict=true', async () => {
Expand All @@ -381,6 +381,32 @@ test.describe('getByAttribute', () => {
const el = await repo.getByAttribute(mockPage, 'TestPage', 'button', 'data-id', 'x');
expect(el).toBeNull();
});

test('defaults to exact-then-contains when exact is not specified', async () => {
const exactLocator = createMockLocator({ getAttribute: async (_name: string) => 'btn-primary' });
const partialLocator = createMockLocator({ getAttribute: async (_name: string) => 'btn-primary extra' });
const baseLocator = createMockLocator({
all: async () => [partialLocator, exactLocator],
});
const mockPage = { locator: () => baseLocator, waitForSelector: async () => {} };
const repo = new ElementRepository(webMockData);
// Should prefer exact match even though partial match appears first
const el = await repo.getByAttribute(mockPage, 'TestPage', 'button', 'class', 'btn-primary');
expect(el).not.toBeNull();
const attr = await el!.getAttribute('class');
expect(attr).toBe('btn-primary');
});

test('falls back to contains match when no exact match exists (default)', async () => {
const partialLocator = createMockLocator({ getAttribute: async (_name: string) => 'btn-primary extra' });
const baseLocator = createMockLocator({
all: async () => [partialLocator],
});
const mockPage = { locator: () => baseLocator, waitForSelector: async () => {} };
const repo = new ElementRepository(webMockData);
const el = await repo.getByAttribute(mockPage, 'TestPage', 'button', 'class', 'btn-primary');
expect(el).not.toBeNull();
});
});

// ===========================================================================
Expand Down
Loading