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
1 change: 1 addition & 0 deletions packages/dom/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@
"test": "NODE_ENV=test mocha"
},
"dependencies": {
"@adobe/css-tools": "^4.4.3",
"fast-deep-equal": "^3.1.3",
"tslib": "^2.6.2"
},
Expand Down
88 changes: 86 additions & 2 deletions packages/dom/src/lib/ElementAssertion.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { Assertion, AssertionError } from "@assertive-ts/core";
import equal from "fast-deep-equal";

import { getExpectedAndReceivedStyles } from "./helpers/helpers";

export class ElementAssertion<T extends Element> extends Assertion<T> {

Expand Down Expand Up @@ -142,8 +145,85 @@ export class ElementAssertion<T extends Element> extends Assertion<T> {
);
}

private getClassList(): string[] {
return this.actual.className.split(/\s+/).filter(Boolean);
/**
* Asserts that the element has the specified CSS styles.
*
* @example
* ```
* expect(component).toHaveStyle({ color: 'green', display: 'block' });
* ```
*
* @param expected the expected CSS styles.
* @returns the assertion instance.
*/

public toHaveStyle(expected: Partial<CSSStyleDeclaration>): this {

const [expectedStyle, receivedStyle] = getExpectedAndReceivedStyles(this.actual, expected);

if (!expectedStyle || !receivedStyle) {
throw new Error("Currently there are no available styles.");
}

const error = new AssertionError({
actual: this.actual,
expected: expectedStyle,
message: `Expected the element to match the following style:\n${JSON.stringify(expectedStyle, null, 2)}`,
});
const invertedError = new AssertionError({
actual: this.actual,
message: `Expected the element to NOT match the following style:\n${JSON.stringify(expectedStyle, null, 2)}`,
});

return this.execute({
assertWhen: equal(expectedStyle, receivedStyle),
error,
invertedError,
});
}

/**
* Asserts that the element has one or more of the specified CSS styles.
*
* @example
* ```
* expect(component).toHaveSomeStyle({ color: 'green', display: 'block' });
* ```
*
* @param expected the expected CSS style/s.
* @returns the assertion instance.
*/

public toHaveSomeStyle(expected: Partial<CSSStyleDeclaration>): this {

const [expectedStyle, elementProcessedStyle] = getExpectedAndReceivedStyles(this.actual, expected);

if (!expectedStyle || !elementProcessedStyle) {
throw new Error("No available styles.");
}

const hasSomeStyle = Object.entries(expectedStyle).some(([expectedProp, expectedValue]) => {
return Object.entries(elementProcessedStyle).some(([receivedProp, receivedValue]) => {
return equal(expectedProp, receivedProp) && equal(expectedValue, receivedValue);
});
});

const error = new AssertionError({
actual: this.actual,
message: `Expected the element to match some of the following styles:\n${JSON.stringify(expectedStyle, null, 2)}`,
});

const invertedError = new AssertionError({
actual: this.actual,
// eslint-disable-next-line max-len
message: `Expected the element NOT to match some of the following styles:\n${JSON.stringify(expectedStyle, null, 2)}`,
});

return this.execute({
assertWhen: hasSomeStyle,
error,
invertedError,
});
}

/**
Expand Down Expand Up @@ -181,4 +261,8 @@ export class ElementAssertion<T extends Element> extends Assertion<T> {
invertedError,
});
}

private getClassList(): string[] {
return this.actual.className.split(/\s+/).filter(Boolean);
}
}
83 changes: 83 additions & 0 deletions packages/dom/src/lib/helpers/helpers.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
export interface CssAtRuleAST {
declarations: StyleDeclaration[];
rules: Rule[];
}

interface Rule {
declarations: StyleDeclaration[];
selectors: string[];
}

interface StyleDeclaration extends Record<string, string> {
property: string;
value: string;
}

const normalizeStyles = (css: Partial<CSSStyleDeclaration>):
{ expectedStyle: StyleDeclaration; props: string[]; } => {
const normalizer = document.createElement("div");
document.body.appendChild(normalizer);

const { props, expectedStyle } = Object.entries(css).reduce(
(acc, [property, value]) => {

if (typeof value !== "string") {
return acc;
}

normalizer.style.setProperty(property, value);

const normalizedValue = window
.getComputedStyle(normalizer)
.getPropertyValue(property)
.trim();

return {
expectedStyle: {
...acc.expectedStyle,
[property]: normalizedValue,
},
props: [...acc.props, property],
};
},
{ expectedStyle: {} as StyleDeclaration, props: [] as string[] },
);

document.body.removeChild(normalizer);

return { expectedStyle, props };
};

const getReceivedStyle = (props: string[], received: CSSStyleDeclaration): StyleDeclaration => {
return props.reduce((acc, prop) => {
const actualStyle = received.getPropertyValue(prop).trim();
if (!actualStyle) {
return acc;
}
acc[prop] = actualStyle;
return acc;
}, {} as StyleDeclaration);
};

export const getExpectedAndReceivedStyles =
(actual: Element, expected: Partial<CSSStyleDeclaration>): StyleDeclaration[] => {
if (!actual.ownerDocument.defaultView) {
throw new Error("The element is not attached to a document with a default view.");
}
if (!(actual instanceof HTMLElement)) {
throw new Error("The element is not an HTMLElement.");
}

const window = actual.ownerDocument.defaultView;

const rawElementStyles = window.getComputedStyle(actual);

const { props, expectedStyle } = normalizeStyles(expected);

const elementProcessedStyle = getReceivedStyle(props, rawElementStyles);

return [
expectedStyle,
elementProcessedStyle,
];
};
119 changes: 117 additions & 2 deletions packages/dom/test/unit/lib/ElementAssertion.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -257,12 +257,127 @@ describe("[Unit] ElementAssertion.test.ts", () => {
const test = new ElementAssertion(divTest);

expect(() => test.toHaveAllClasses("foo", "bar", "baz"))
.toThrowError(AssertionError)
.toHaveMessage('Expected the element to have all of these classes: "foo bar baz"');
.toThrowError(AssertionError)
.toHaveMessage('Expected the element to have all of these classes: "foo bar baz"');

expect(test.not.toHaveAllClasses("foo", "bar", "baz")).toBeEqual(test);
});
});
});

describe(".toHaveStyle", () => {
context("when the element has the expected style", () => {
it("returns the assertion instance", () => {
const { getByTestId } = render(
<div
className="foo bar test"
style={{ border: "1px solid black", color: "red", display: "flex" }}
data-testid="test-div"
/>);
const divTest = getByTestId("test-div");
const test = new ElementAssertion(divTest);

expect(test.toHaveStyle({ border: "1px solid black", color: "red", display: "flex" })).toBeEqual(test);

expect(() => test.not.toHaveStyle({ border: "1px solid black", color: "red", display: "flex" }))
.toThrowError(AssertionError)
.toHaveMessage(
// eslint-disable-next-line max-len
'Expected the element to NOT match the following style:\n{\n "border": "1px solid black",\n "color": "rgb(255, 0, 0)",\n "display": "flex"\n}',
);
});
});

context("when the element does not have the expected style", () => {
it("throws an assertion error", () => {
const { getByTestId } = render(
<div
className="foo bar test"
style={{ color: "blue", display: "block" }}
data-testid="test-div"
/>,
);

const divTest = getByTestId("test-div");
const test = new ElementAssertion(divTest);

expect(() => test.toHaveStyle(({ border: "1px solid black", color: "red", display: "flex" })))
.toThrowError(AssertionError)
.toHaveMessage(
// eslint-disable-next-line max-len
'Expected the element to match the following style:\n{\n "border": "1px solid black",\n "color": "rgb(255, 0, 0)",\n "display": "flex"\n}',
);

expect(test.not.toHaveStyle({ border: "1px solid black", color: "red", display: "flex" })).toBeEqual(test);

});
});
context("when the element partially match the style", () => {
it("throws an assertion error", () => {
const { getByTestId } = render(
<div
className="foo bar test"
style={{ border: "1px solid black", color: "blue", display: "block" }}
data-testid="test-div"
/>,
);

const divTest = getByTestId("test-div");
const test = new ElementAssertion(divTest);

expect(() => test.toHaveStyle(({ color: "red", display: "flex" })))
.toThrowError(AssertionError)
.toHaveMessage(
// eslint-disable-next-line max-len
'Expected the element to match the following style:\n{\n "color": "rgb(255, 0, 0)",\n "display": "flex"\n}',
);

expect(test.not.toHaveStyle({ border: "1px solid black", color: "red", display: "flex" })).toBeEqual(test);

});
});
});

describe(".toHaveSomeStyle", () => {
context("when the element contains one or more expected styles", () => {
it("returns the assertion instance", () => {
const { getByTestId } = render(
<div
style={{ color: "blue", maxHeight: "3rem", width: "2rem" }}
data-testid="test-div"
/>,
);
const divTest = getByTestId("test-div");
const test = new ElementAssertion(divTest);

expect(test.toHaveSomeStyle({ color: "red", display: "flex", height: "3rem", width: "2rem" })).toBeEqual(test);

expect(() => test.not.toHaveSomeStyle({ color: "blue" }))
.toThrowError(AssertionError)
// eslint-disable-next-line max-len
.toHaveMessage("Expected the element NOT to match some of the following styles:\n{\n \"color\": \"rgb(0, 0, 255)\"\n}");
});
});

context("when the element does not contain any of the expected styles", () => {
it("throws an assertion error", () => {
const { getByTestId } = render(
<div
className="foo bar test"
style={{ border: "1px solid black", color: "blue", display: "block" }}
data-testid="test-div"
/>,
);
const divTest = getByTestId("test-div");
const test = new ElementAssertion(divTest);

expect(() => test.toHaveSomeStyle({ color: "red", display: "flex" }))
.toThrowError(AssertionError)
// eslint-disable-next-line max-len
.toHaveMessage("Expected the element to match some of the following styles:\n{\n \"color\": \"rgb(255, 0, 0)\",\n \"display\": \"flex\"\n}");

expect(test.not.toHaveSomeStyle({ border: "1px solid blue", color: "red", display: "flex" })).toBeEqual(test);
});
});
});
});
8 changes: 8 additions & 0 deletions yarn.lock
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,13 @@ __metadata:
languageName: node
linkType: hard

"@adobe/css-tools@npm:^4.4.3":
version: 4.4.3
resolution: "@adobe/css-tools@npm:4.4.3"
checksum: 10/701379c514b7a43ca6681705a93cd57ad79565cfef9591122e9499897550cf324a5e5bb1bc51df0e7433cf0e91b962c90f18ac459dcc98b2431daa04aa63cb20
languageName: node
linkType: hard

"@ampproject/remapping@npm:^2.2.0":
version: 2.2.1
resolution: "@ampproject/remapping@npm:2.2.1"
Expand Down Expand Up @@ -49,6 +56,7 @@ __metadata:
version: 0.0.0-use.local
resolution: "@assertive-ts/dom@workspace:packages/dom"
dependencies:
"@adobe/css-tools": "npm:^4.4.3"
"@assertive-ts/core": "workspace:^"
"@testing-library/dom": "npm:^10.1.0"
"@testing-library/react": "npm:^16.0.0"
Expand Down