From 8d6fb51908f28319d07e27ac933d813d8d795608 Mon Sep 17 00:00:00 2001 From: Simon Backx Date: Thu, 22 Jun 2023 13:55:05 +0200 Subject: [PATCH] Added Playwright tests to comments-ui refs https://github.com/TryGhost/Team/issues/3504 Not complete yet, but contains the basic structure and a few tests that work and should run in CI. --- .github/workflows/comments-ui-tests.yml | 3 + .gitignore | 3 + apps/comments-ui/package.json | 9 +- apps/comments-ui/playwright.config.ts | 62 +++++++++ .../src/components/content/CTABox.js | 2 +- .../src/components/content/ContentTitle.js | 6 +- apps/comments-ui/test/e2e/cta.test.ts | 62 +++++++++ apps/comments-ui/test/e2e/options.test.ts | 72 ++++++++++ apps/comments-ui/test/e2e/pagination.test.ts | 62 +++++++++ apps/comments-ui/test/unit/hello.test.js | 8 ++ apps/comments-ui/test/utils/MockedApi.ts | 128 ++++++++++++++++++ apps/comments-ui/test/utils/e2e.ts | 62 +++++++++ apps/comments-ui/test/utils/fixtures.ts | 65 +++++++++ apps/comments-ui/vite.config.js | 5 +- apps/signup-form/test/utils/e2e.ts | 2 +- .../unit/frontend/helpers/comments.test.js | 6 - 16 files changed, 541 insertions(+), 16 deletions(-) create mode 100644 apps/comments-ui/playwright.config.ts create mode 100644 apps/comments-ui/test/e2e/cta.test.ts create mode 100644 apps/comments-ui/test/e2e/options.test.ts create mode 100644 apps/comments-ui/test/e2e/pagination.test.ts create mode 100644 apps/comments-ui/test/unit/hello.test.js create mode 100644 apps/comments-ui/test/utils/MockedApi.ts create mode 100644 apps/comments-ui/test/utils/e2e.ts create mode 100644 apps/comments-ui/test/utils/fixtures.ts diff --git a/.github/workflows/comments-ui-tests.yml b/.github/workflows/comments-ui-tests.yml index b64905654c..f649f9e59f 100644 --- a/.github/workflows/comments-ui-tests.yml +++ b/.github/workflows/comments-ui-tests.yml @@ -32,6 +32,9 @@ jobs: - run: yarn --prefer-offline + - name: Install Playwright + run: npx playwright install --with-deps + - run: yarn workspace @tryghost/comments-ui run test - name: Upload test results diff --git a/.gitignore b/.gitignore index 3d3f78ab39..2694b37cfd 100644 --- a/.gitignore +++ b/.gitignore @@ -127,6 +127,9 @@ Caddyfile # Comments-UI /apps/comments-ui/umd +/apps/comments-ui/playwright-report +/ghost/comments-ui/playwright/.cache/ +/ghost/comments-ui/test-results/ # Portal !/apps/portal/.env diff --git a/apps/comments-ui/package.json b/apps/comments-ui/package.json index 5eaf92e243..964a89315d 100644 --- a/apps/comments-ui/package.json +++ b/apps/comments-ui/package.json @@ -16,12 +16,14 @@ }, "scripts": { "dev": "concurrently \"yarn preview -l silent\" \"yarn build:watch\"", + "dev:test": "vite build && vite preview --port 7175", "build": "vite build", "build:watch": "vite build --watch", "preview": "vite preview", - "test": "vitest run", - "test:ci": "yarn test --coverage", - "test:unit": "yarn build", + "test": "vitest run --coverage && yarn test:e2e", + "test:e2e": "NODE_OPTIONS='--experimental-specifier-resolution=node --no-warnings' VITE_TEST=true playwright test", + "test:slowmo": "TIMEOUT=100000 PLAYWRIGHT_SLOWMO=1000 yarn test:e2e --headed", + "test:e2e:full": "ALL_BROWSERS=1 yarn test:e2e", "lint": "eslint src --ext .js --cache", "preship": "yarn lint", "ship": "STATUS=$(git status --porcelain); echo $STATUS; if [ -z \"$STATUS\" ]; then yarn version; fi", @@ -55,6 +57,7 @@ "react-dom": "17.0.2" }, "devDependencies": { + "@playwright/test": "1.35.1", "@testing-library/jest-dom": "5.16.5", "@testing-library/react": "12.1.5", "@testing-library/user-event": "14.4.3", diff --git a/apps/comments-ui/playwright.config.ts b/apps/comments-ui/playwright.config.ts new file mode 100644 index 0000000000..8519390d29 --- /dev/null +++ b/apps/comments-ui/playwright.config.ts @@ -0,0 +1,62 @@ +import {defineConfig, devices} from '@playwright/test'; + +export const E2E_PORT = 7175; + +/** + * See https://playwright.dev/docs/test-configuration. + */ +export default defineConfig({ + testDir: './test/e2e', + /* Run tests in files in parallel */ + fullyParallel: true, + /* Fail the build on CI if you accidentally left test.only in the source code. */ + forbidOnly: !!process.env.CI, + /* Retry on CI only */ + retries: process.env.CI ? 2 : 0, + /* Opt out of parallel tests on CI. */ + workers: process.env.CI ? 1 : undefined, + /* Reporter to use. See https://playwright.dev/docs/test-reporters */ + reporter: 'html', + timeout: process.env.PLAYWRIGHT_SLOWMO ? 100000 : 10000, + expect: { + timeout: process.env.PLAYWRIGHT_SLOWMO ? 100000 : 5000 + }, + + /* Shared settings for all the projects below. See https://playwright.dev/docs/api/class-testoptions. */ + use: { + /* Collect trace when retrying the failed test. See https://playwright.dev/docs/trace-viewer */ + trace: 'on-first-retry', + launchOptions: { + slowMo: parseInt(process.env.PLAYWRIGHT_SLOWMO ?? '') || 0, + // force GPU hardware acceleration + // (even in headless mode) + args: ['--use-gl=egl'] + } + }, + + /* Configure projects for major browsers */ + projects: [ + { + name: 'chromium', + use: {...devices['Desktop Chrome']} + }, + + ...(process.env.ALL_BROWSERS ? [{ + name: 'firefox', + use: {...devices['Desktop Firefox']} + }, + + { + name: 'webkit', + use: {...devices['Desktop Safari']} + }] : []) + ], + + /* Run local dev server before starting the tests */ + webServer: { + command: `yarn dev:test`, + url: `http://localhost:${E2E_PORT}/comments-ui.min.js`, + reuseExistingServer: !process.env.CI, + timeout: 10000 + } +}); diff --git a/apps/comments-ui/src/components/content/CTABox.js b/apps/comments-ui/src/components/content/CTABox.js index 8ad4d12a21..4d394edc07 100644 --- a/apps/comments-ui/src/components/content/CTABox.js +++ b/apps/comments-ui/src/components/content/CTABox.js @@ -23,7 +23,7 @@ const CTABox = ({isFirst, isPaid}) => { }; return ( -
+

{titleText}

diff --git a/apps/comments-ui/src/components/content/ContentTitle.js b/apps/comments-ui/src/components/content/ContentTitle.js index ccc9a921e4..184c48bc9b 100644 --- a/apps/comments-ui/src/components/content/ContentTitle.js +++ b/apps/comments-ui/src/components/content/ContentTitle.js @@ -7,12 +7,12 @@ const Count = ({showCount, count}) => { if (count === 1) { return ( -
1 comment
+
1 comment
); } return ( -
{formatNumber(count)} comments
+
{formatNumber(count)} comments
); }; @@ -34,7 +34,7 @@ const ContentTitle = ({title, showCount, count}) => { return (
-

+

</h2> <Count count={count} showCount={showCount} /> diff --git a/apps/comments-ui/test/e2e/cta.test.ts b/apps/comments-ui/test/e2e/cta.test.ts new file mode 100644 index 0000000000..758744d96f --- /dev/null +++ b/apps/comments-ui/test/e2e/cta.test.ts @@ -0,0 +1,62 @@ +import {MockedApi, initialize} from '../utils/e2e'; +import {expect, test} from '@playwright/test'; + +test.describe('CTA', async () => { + test('Shows CTA when not logged in', async ({page}) => { + const mockedApi = new MockedApi({}); + mockedApi.addComments(2); + + const {frame} = await initialize({ + mockedApi, + page, + publication: 'Publisher Weekly' + }); + + const ctaBox = await frame.getByTestId('cta-box'); + await expect(ctaBox).toBeVisible(); + + await expect(ctaBox).toContainText('Join the discussion'); + await expect(ctaBox).toContainText('Become a member of Publisher Weekly to start commenting'); + await expect(ctaBox).toContainText('Sign in'); + }); + + test('Shows different CTA if no comments', async ({page}) => { + const mockedApi = new MockedApi({}); + + const {frame} = await initialize({ + mockedApi, + page, + publication: 'Publisher Weekly' + }); + + const ctaBox = await frame.getByTestId('cta-box'); + await expect(ctaBox).toBeVisible(); + + await expect(ctaBox).toContainText('Start the conversation'); + }); + + test('Shows CTA when logged in, but not a paid member and comments are paid only', async ({page}) => { + const mockedApi = new MockedApi({}); + mockedApi.addComments(2); + mockedApi.setMember({ + status: 'free' + }); + + const {frame} = await initialize({ + mockedApi, + page, + publication: 'Publisher Weekly', + commentsEnabled: 'paid' + }); + + const ctaBox = await frame.getByTestId('cta-box'); + await expect(ctaBox).toBeVisible(); + + await expect(ctaBox).toContainText('Join the discussion'); + await expect(ctaBox).toContainText('Become a paid member of Publisher Weekly to start commenting'); + + // Don't show sign in button + await expect(ctaBox).not.toContainText('Sign in'); + }); +}); + diff --git a/apps/comments-ui/test/e2e/options.test.ts b/apps/comments-ui/test/e2e/options.test.ts new file mode 100644 index 0000000000..5e1b9546ed --- /dev/null +++ b/apps/comments-ui/test/e2e/options.test.ts @@ -0,0 +1,72 @@ +import {MockedApi, initialize} from '../utils/e2e'; +import {expect, test} from '@playwright/test'; + +test.describe('Options', async () => { + test('Shows the title and count', async ({page}) => { + const mockedApi = new MockedApi({}); + mockedApi.addComments(2); + + const {frame} = await initialize({ + mockedApi, + page, + title: 'Leave a comment', + publication: 'Publisher Weekly', + count: true + }); + + // Check text 'Leave a comment' is present + await expect(frame.getByTestId('title')).toHaveText('Leave a comment'); + await expect(frame.getByTestId('count')).toHaveText('2 comments'); + }); + + test('Shows the title and singular count', async ({page}) => { + const mockedApi = new MockedApi({}); + mockedApi.addComments(1); + + const {frame} = await initialize({ + mockedApi, + page, + title: 'Leave a comment', + publication: 'Publisher Weekly', + count: true + }); + + // Check text 'Leave a comment' is present + await expect(frame.getByTestId('title')).toHaveText('Leave a comment'); + await expect(frame.getByTestId('count')).toHaveText('1 comment'); + }); + + test('Shows the title but hides the count', async ({page}) => { + const mockedApi = new MockedApi({}); + mockedApi.addComments(2); + + const {frame} = await initialize({ + mockedApi, + page, + title: 'Leave a comment', + publication: 'Publisher Weekly', + count: false + }); + + // Check text 'Leave a comment' is present + await expect(frame.getByTestId('title')).toHaveText('Leave a comment'); + + // Check count is hidden + await expect(frame.getByTestId('count')).not.toBeVisible(); + }); + + test('Hides title and count', async ({page}) => { + const mockedApi = new MockedApi({}); + mockedApi.addComments(2); + + const {frame} = await initialize({ + mockedApi, + page, + publication: 'Publisher Weekly' + }); + + await expect(frame.getByTestId('title')).not.toBeVisible(); + await expect(frame.getByTestId('count')).not.toBeVisible(); + }); +}); + diff --git a/apps/comments-ui/test/e2e/pagination.test.ts b/apps/comments-ui/test/e2e/pagination.test.ts new file mode 100644 index 0000000000..ae39dcfc15 --- /dev/null +++ b/apps/comments-ui/test/e2e/pagination.test.ts @@ -0,0 +1,62 @@ +import {MockedApi, initialize} from '../utils/e2e'; +import {expect, test} from '@playwright/test'; + +test.describe('Pagination', async () => { + test('Shows pagination button on top if more than 5 comments', async ({page}) => { + const mockedApi = new MockedApi({}); + + mockedApi.addComment({ + html: '<p>This is comment 1</p>' + }); + mockedApi.addComment({ + html: '<p>This is comment 2</p>' + }); + mockedApi.addComment({ + html: '<p>This is comment 3</p>' + }); + mockedApi.addComment({ + html: '<p>This is comment 4</p>' + }); + mockedApi.addComment({ + html: '<p>This is comment 5</p>' + }); + mockedApi.addComment({ + html: '<p>This is comment 6</p>' + }); + + const {frame} = await initialize({ + mockedApi, + page, + publication: 'Publisher Weekly' + }); + + await expect(frame.getByTestId('pagination-component')).toBeVisible(); + + // Check text in pagination button + await expect(frame.getByTestId('pagination-component')).toContainText('Show 1 previous comment'); + + // Test total comments with test-id comment-component is 5 + await expect(frame.getByTestId('comment-component')).toHaveCount(5); + + // Check only the first 5 comments are visible + await expect(frame.getByText('This is comment 1')).toBeVisible(); + await expect(frame.getByText('This is comment 2')).toBeVisible(); + await expect(frame.getByText('This is comment 3')).toBeVisible(); + await expect(frame.getByText('This is comment 4')).toBeVisible(); + await expect(frame.getByText('This is comment 5')).toBeVisible(); + await expect(frame.getByText('This is comment 6')).not.toBeVisible(); + + // Click the pagination button + await frame.getByTestId('pagination-component').click(); + + // Check only 6 visible (not more than that) + await expect(frame.getByTestId('comment-component')).toHaveCount(6); + + // Check comments 6 is visible + await expect(frame.getByText('This is comment 6')).toBeVisible(); + + // Check the pagination button is not visible + await expect(frame.getByTestId('pagination-component')).not.toBeVisible(); + }); +}); + diff --git a/apps/comments-ui/test/unit/hello.test.js b/apps/comments-ui/test/unit/hello.test.js new file mode 100644 index 0000000000..3a89a84858 --- /dev/null +++ b/apps/comments-ui/test/unit/hello.test.js @@ -0,0 +1,8 @@ +const assert = require('assert/strict'); + +describe('Hello world', function () { + it('Runs a test', function () { + // TODO: Write me! + assert.ok(require('../../index')); + }); +}); diff --git a/apps/comments-ui/test/utils/MockedApi.ts b/apps/comments-ui/test/utils/MockedApi.ts new file mode 100644 index 0000000000..e673235889 --- /dev/null +++ b/apps/comments-ui/test/utils/MockedApi.ts @@ -0,0 +1,128 @@ +import nql from '@tryghost/nql'; +import {buildComment, buildMember} from './fixtures'; + +export class MockedApi { + comments: any[]; + postId: string; + member: any; + + #lastCommentDate = new Date('2021-01-01T00:00:00.000Z'); + + constructor({postId = 'ABC', comments = [], member = undefined}: {postId?: string, comments?: any[], member?: any}) { + this.postId = postId; + this.comments = comments; + this.member = member; + } + + addComment(overrides: any = {}) { + if (!overrides.created_at) { + overrides.created_at = this.#lastCommentDate.toISOString(); + this.#lastCommentDate = new Date(this.#lastCommentDate.getTime() + 1); + } + + const fixture = buildComment({ + ...overrides, + post_id: this.postId + }); + this.comments.push(fixture); + } + + addComments(count, overrides = {}) { + for (let i = 0; i < count; i += 1) { + this.addComment(overrides); + } + } + + setMember(overrides) { + this.member = buildMember(overrides); + } + + commentsCounts() { + return { + [this.postId]: this.comments.length + }; + } + + browseComments({limit = 5, order, filter, page}: {limit?: number, order?: string, filter?: string, page: number}) { + // Sort comments on created at + id + this.comments.sort((a, b) => { + const aDate = new Date(a.created_at).getTime(); + const bDate = new Date(b.created_at).getTime(); + + if (aDate === bDate) { + return a.id > b.id ? 1 : -1; + } + + return aDate > bDate ? 1 : -1; + }); + + let filteredComments = this.comments; + + // Parse NQL filter + if (filter) { + const parsed = nql(filter); + filteredComments = this.comments.filter((comment) => { + return parsed.queryJSON(comment); + }); + } + + // Splice based on page and limit + const startIndex = (page - 1) * limit; + const endIndex = startIndex + limit; + const comments = filteredComments.slice(startIndex, endIndex); + + return { + comments, + meta: { + pagination: { + pages: Math.ceil(filteredComments.length / limit), + total: filteredComments.length, + page, + limit + } + } + }; + } + + async listen({page, path}: {page: any, path: string}) { + await page.route(`${path}/members/api/member/`, async (route) => { + if (!this.member) { + return await route.fulfill({ + status: 401, + body: 'Not authenticated' + }); + } + + await route.fulfill({ + status: 200, + body: JSON.stringify(this.member) + }); + }); + + await page.route(`${path}/members/api/comments/*`, async (route) => { + const url = new URL(route.request().url()); + + const p = parseInt(url.searchParams.get('page') ?? '1'); + const limit = parseInt(url.searchParams.get('limit') ?? '5'); + const order = url.searchParams.get('order') ?? ''; + + await route.fulfill({ + status: 200, + body: JSON.stringify(this.browseComments({ + page: p, + limit, + order + })) + }); + }); + + await page.route(`${path}/members/api/comments/counts/*`, async (route) => { + await route.fulfill({ + status: 200, + body: JSON.stringify( + this.commentsCounts() + ) + }); + }); + } +} diff --git a/apps/comments-ui/test/utils/e2e.ts b/apps/comments-ui/test/utils/e2e.ts new file mode 100644 index 0000000000..a96e9017d8 --- /dev/null +++ b/apps/comments-ui/test/utils/e2e.ts @@ -0,0 +1,62 @@ +import {E2E_PORT} from '../../playwright.config'; +import {MockedApi} from './MockedApi'; +import {Page} from '@playwright/test'; + +export const MOCKED_SITE_URL = 'https://localhost:1234'; +export {MockedApi}; + +export async function initialize({mockedApi, page, ...options}: { + mockedApi: MockedApi, + page: Page, + path?: string; + ghostComments?: string, + key?: string, + api?: string, + admin?: string, + colorScheme?: string, + avatarSaturation?: string, + accentColor?: string, + commentsEnabled?: string, + title?: string, + count?: boolean, + publication?: string, + postId?: string +}) { + const sitePath = MOCKED_SITE_URL; + await page.route(sitePath, async (route) => { + await route.fulfill({ + status: 200, + body: '<html><head><meta charset="UTF-8" /></head><body></body></html>' + }); + }); + + const url = `http://localhost:${E2E_PORT}/comments-ui.min.js`; + await page.setViewportSize({width: 1000, height: 1000}); + + await page.goto(sitePath); + await mockedApi.listen({page, path: sitePath}); + + if (!options.ghostComments) { + options.ghostComments = MOCKED_SITE_URL; + } + + if (!options.postId) { + options.postId = mockedApi.postId; + } + + await page.evaluate((data) => { + const scriptTag = document.createElement('script'); + scriptTag.src = data.url; + + for (const option of Object.keys(data.options)) { + scriptTag.dataset[option] = data.options[option]; + } + document.body.appendChild(scriptTag); + }, {url, options}); + + await page.waitForSelector('iframe'); + + return { + frame: page.frameLocator('iframe') + }; +} diff --git a/apps/comments-ui/test/utils/fixtures.ts b/apps/comments-ui/test/utils/fixtures.ts new file mode 100644 index 0000000000..7033d55b7d --- /dev/null +++ b/apps/comments-ui/test/utils/fixtures.ts @@ -0,0 +1,65 @@ +const ObjectId = require('bson-objectid').default; +let memberCounter = 0; + +export function buildMember(override: any = {}) { + memberCounter += 1; + + return { + avatar_image: 'https://www.gravatar.com/avatar/7a68f69cc9c9e9b45d97ecad6f24184a?s=250&r=g&d=blank', + expertise: 'Head of Testing', + id: ObjectId(), + name: 'Test Member ' + memberCounter, + uuid: ObjectId(), + paid: override.status === 'paid', + status: 'free', + ...override + }; +} + +export function buildComment(override: any = {}) { + return { + id: ObjectId(), + html: '<p>Empty</p>', + replies: [], + count: { + replies: 0, + likes: 0 + }, + liked: false, + created_at: '2022-08-11T09:26:34.000Z', + edited_at: null, + member: buildMember(), + status: 'published', + ...override + }; +} + +export function buildReply(override: any = {}) { + return { + id: ObjectId(), + html: '<p>Empty</p>', + count: { + likes: 0 + }, + liked: false, + created_at: '2022-08-11T09:26:34.000Z', + edited_at: null, + member: buildMember(), + status: 'published', + ...override + }; +} + +export function buildCommentsReply(override: any = {}) { + return { + comments: [], + meta: { + pagination: { + pages: 1, + total: 0, + page: 1, + limit: 5 + } + } + }; +} diff --git a/apps/comments-ui/vite.config.js b/apps/comments-ui/vite.config.js index 5419c9fe4e..4720f7aa30 100644 --- a/apps/comments-ui/vite.config.js +++ b/apps/comments-ui/vite.config.js @@ -1,10 +1,10 @@ -import {resolve} from 'path'; import fs from 'fs/promises'; +import {resolve} from 'path'; -import {defineConfig} from 'vitest/config'; import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js'; import reactPlugin from '@vitejs/plugin-react'; import svgrPlugin from 'vite-plugin-svgr'; +import {defineConfig} from 'vitest/config'; import pkg from './package.json'; @@ -75,6 +75,7 @@ export default defineConfig((config) => { globals: true, environment: 'jsdom', setupFiles: './src/setupTests.js', + include: ['src/**/*.test.js'], testTimeout: 10000 } }; diff --git a/apps/signup-form/test/utils/e2e.ts b/apps/signup-form/test/utils/e2e.ts index 34400c72e5..dd51878b20 100644 --- a/apps/signup-form/test/utils/e2e.ts +++ b/apps/signup-form/test/utils/e2e.ts @@ -12,7 +12,7 @@ export async function initialize({page, path, apiStatus, embeddedOnUrl, ...optio await page.route(sitePath, async (route) => { await route.fulfill({ status: 200, - body: '<html><body></body></html>' + body: '<html><head><meta charset="UTF-8" /></head><body></body></html>' }); }); diff --git a/ghost/core/test/unit/frontend/helpers/comments.test.js b/ghost/core/test/unit/frontend/helpers/comments.test.js index 4c2ad04973..11e22dab02 100644 --- a/ghost/core/test/unit/frontend/helpers/comments.test.js +++ b/ghost/core/test/unit/frontend/helpers/comments.test.js @@ -60,15 +60,12 @@ describe('{{comments}} helper', function () { rendered.string.should.containEql('data-api="http://127.0.0.1:2369/ghost/api/content/"'); rendered.string.should.containEql('data-admin="http://127.0.0.1:2369/ghost/"'); rendered.string.should.containEql('data-key="xyz"'); - rendered.string.should.containEql('data-styles="https://cdn.jsdelivr.net/ghost/comments-ui@~test.version/umd/main.css"'); rendered.string.should.containEql('data-title="null"'); rendered.string.should.containEql('data-count="true"'); rendered.string.should.containEql('data-post-id="post_id_123"'); - rendered.string.should.containEql('data-sentry-dsn=""'); rendered.string.should.containEql('data-color-scheme="auto"'); rendered.string.should.containEql('data-avatar-saturation="60"'); rendered.string.should.containEql('data-accent-color=""'); - rendered.string.should.containEql('data-app-version="test.version"'); rendered.string.should.containEql('data-comments-enabled="all"'); }); @@ -92,15 +89,12 @@ describe('{{comments}} helper', function () { rendered.string.should.containEql('data-api="http://127.0.0.1:2369/ghost/api/content/"'); rendered.string.should.containEql('data-admin="http://127.0.0.1:2369/ghost/"'); rendered.string.should.containEql('data-key="xyz"'); - rendered.string.should.containEql('data-styles="https://cdn.jsdelivr.net/ghost/comments-ui@~test.version/umd/main.css"'); rendered.string.should.containEql('data-title="null"'); rendered.string.should.containEql('data-count="true"'); rendered.string.should.containEql('data-post-id="post_id_123"'); - rendered.string.should.containEql('data-sentry-dsn=""'); rendered.string.should.containEql('data-color-scheme="auto"'); rendered.string.should.containEql('data-avatar-saturation="60"'); rendered.string.should.containEql('data-accent-color=""'); - rendered.string.should.containEql('data-app-version="test.version"'); rendered.string.should.containEql('data-comments-enabled="paid"'); });