feat: v4.0.0 (#1016)

Co-authored-by: Denys Konovalov <kontakt@denyskon.de>
Co-authored-by: Mathieu COSYNS <64072917+Mathieu-COSYNS@users.noreply.github.com>
This commit is contained in:
Daniel Lautzenheiser
2024-01-03 15:14:09 -05:00
committed by GitHub
parent 682576ffc4
commit 799c7e6936
732 changed files with 48477 additions and 10886 deletions

View File

@ -23,7 +23,7 @@ jobs:
packages/app/yarn.lock
packages/core/yarn.lock
packages/docs/yarn.lock
node-version: 16
node-version: 18
- name: Install
run: |
@ -50,7 +50,7 @@ jobs:
packages/app/yarn.lock
packages/core/yarn.lock
packages/docs/yarn.lock
node-version: 16
node-version: 18
- name: Install
run: |
@ -75,7 +75,7 @@ jobs:
packages/app/yarn.lock
packages/core/yarn.lock
packages/docs/yarn.lock
node-version: 16
node-version: 18
- name: Install
run: |
@ -105,7 +105,7 @@ jobs:
packages/app/yarn.lock
packages/core/yarn.lock
packages/docs/yarn.lock
node-version: 16
node-version: 18
- name: Install
run: |

49
.github/workflows/cypress.yml vendored Normal file
View File

@ -0,0 +1,49 @@
name: Cypress Tests
on:
workflow_dispatch:
push:
branches: ['main', 'next']
pull_request:
branches: ['main', 'next']
jobs:
cypress-run:
runs-on: ubuntu-latest
strategy:
fail-fast: false # https://github.com/cypress-io/github-action/issues/48
matrix:
containers: [1, 2] # Uses 2 parallel instances
steps:
- uses: actions/checkout@v3
- name: Setup Node
uses: actions/setup-node@v3
with:
cache: yarn
cache-dependency-path: |
yarn.lock
packages/app/yarn.lock
packages/core/yarn.lock
packages/docs/yarn.lock
node-version: 18
- name: Install
run: |
yarn install --frozen-lockfile
- name: Run Cypress Tests
uses: cypress-io/github-action@v6
with:
start: yarn dev
wait-on: 'http://localhost:8080'
record: true # Records to Cypress Cloud
parallel: true # Runs test in parallel using settings above
env:
# For recording and parallelization to work you must set your CYPRESS_RECORD_KEY
# in GitHub repo → Settings → Secrets → Actions
CYPRESS_RECORD_KEY: ${{ secrets.CYPRESS_RECORD_KEY }}
# Creating a token https://docs.github.com/en/authentication/keeping-your-account-and-data-secure/creating-a-personal-access-token
GITHUB_TOKEN: ${{ secrets.CYPRESS_GITHUB_TOKEN }}

4
.gitignore vendored
View File

@ -2,3 +2,7 @@
/node_modules
*.log
.vscode
cypress/screenshots
cypress/downloads
.env
.temp

4
.prettierignore Normal file
View File

@ -0,0 +1,4 @@
dist/
bin/
public/
.cache/

6
.prettierrc Normal file
View File

@ -0,0 +1,6 @@
{
"arrowParens": "avoid",
"trailingComma": "all",
"singleQuote": true,
"printWidth": 100
}

19
cypress.config.ts Normal file
View File

@ -0,0 +1,19 @@
import { defineConfig } from "cypress";
import setupNodeEvents from "./cypress/plugins";
export default defineConfig({
projectId: "wvw3x3",
retries: {
runMode: 2,
openMode: 0,
},
chromeWebSecurity: false,
e2e: {
video: false,
// We've imported your old cypress plugins here.
// You may want to clean this up later by importing these.
setupNodeEvents,
baseUrl: "http://localhost:8080",
specPattern: "cypress/e2e/*.spec.ts",
},
});

72
cypress/README.md Normal file
View File

@ -0,0 +1,72 @@
# Cypress Tests Guide
## Introduction
[Cypress](https://www.cypress.io/) is a JavaScript End to End Testing Framework that runs in the browser.
Cypress tests run with a [local version](../packages/core/dev-test) of the CMS.
During the setup of a spec file, the relevant `index.html` and `config.yml` are copied from `packages/core/dev-test/backends/<backend>` to `dev-test`.
Tests for the `test` backend use mock data generated in `dev-test/backends/test/index.html`.
Tests for the other backends use previously [recorded data](fixtures) and stub `fetch` [calls](support/commands.js#L52). See more about recording tests data [here](#recording-tests-data).
## Run Tests Locally
```bash
yarn test:e2e # builds the demo site and runs Cypress in headless mode with mock data
```
## Debug Tests
```bash
yarn develop # starts a local dev server with the demo site
yarn test:e2e:exec # runs Cypress in non-headless mode with mock data
```
## Recording Tests Data
When recording tests, access to the relevant backend API is required, thus one must set up a `.env` file in the root project directory in the following format:
```bash
GITHUB_REPO_OWNER=owner
GITHUB_REPO_NAME=repo
GITHUB_REPO_TOKEN=tokenWithWritePermissions
GITHUB_OPEN_AUTHORING_OWNER=forkOwner
GITHUB_OPEN_AUTHORING_TOKEN=tokenWithWritePermissions
GITLAB_REPO_OWNER=owner
GITLAB_REPO_NAME=repo
GITLAB_REPO_TOKEN=tokenWithWritePermissions
BITBUCKET_REPO_OWNER=owner
BITBUCKET_REPO_NAME=repo
BITBUCKET_OUATH_CONSUMER_KEY=ouathConsumerKey
BITBUCKET_OUATH_CONSUMER_SECRET=ouathConsumerSecret
NETLIFY_API_TOKEN=netlifyApiToken
NETLIFY_INSTALLATION_ID=netlifyGitHubInstallationId
```
> The structure of the relevant repo should match the settings in [`config.yml`](../packages/core/dev-test/backends/<backend>/config.yml#L1)
To start a recording run the following commands:
```bash
yarn develop # starts a local dev server with the demo site
yarn mock:server:start # starts the recording proxy
yarn test:e2e:record-fixtures:dev # runs Cypress in non-headless and pass data through the recording proxy
yarn mock:server:stop # stops the recording proxy
```
> During the recorded process a clone of the relevant repo will be created under `.temp` and reset between tests.
Recordings are sanitized from any possible sensitive data and [transformed](plugins/common.js#L34) into an easier to process format.
To avoid recording all the tests over and over again, a recommended process is to:
1. Mark the specific test as `only` by changing `it("some test...` to `it.only("some test...` for the relevant test.
2. Run the test in recording mode.
3. Exit Cypress and stop the proxy.
4. Run the test normally (with mock data) to verify the recording works.

82
cypress/cypress.d.ts vendored Normal file
View File

@ -0,0 +1,82 @@
/// <reference types="cypress" />
import type {
SetupBackendProps,
SetupBackendTestProps,
SeedRepoProps,
TeardownBackendTestProps,
TeardownBackendProps,
} from './interface';
import type { Config as CMSConfig, DeepPartial } from '@staticcms/core/interface';
interface KeyProps {
shift?: boolean;
times?: number;
}
declare global {
namespace Cypress {
interface Chainable {
task(event: 'setupBackend', props: SetupBackendProps): Chainable<SetupBackendResponse>;
task(event: 'setupBackendTest', props: SetupBackendTestProps): Chainable<Promise<null>>;
task(event: 'seedRepo', props: SeedRepoProps): Chainable<Promise<null>>;
task(event: 'teardownBackendTest', props: TeardownBackendTestProps): Chainable<Promise<null>>;
task(event: 'teardownBackend', props: TeardownBackendProps): Chainable<Promise<null>>;
task(event: 'updateConfig', props: DeepPartial<CMSConfig>): Chainable<Promise<null>>;
login(): Chainable;
loginAndNewPost(): Chainable;
dragTo(selector: string, options?: { delay?: number }): Chainable;
getMarkdownEditor(): Chainable;
confirmMarkdownEditorContent(expected: string): Chainable;
clearMarkdownEditorContent(): Chainable;
confirmRawEditorContent(expected: string): Chainable;
enter(props?: KeyProps): Chainable;
backspace(props?: KeyProps): Chainable;
selectAll(props?: KeyProps): Chainable;
up(props?: KeyProps): Chainable;
down(props?: KeyProps): Chainable;
left(props?: KeyProps): Chainable;
right(props?: KeyProps): Chainable;
tabkey(props?: KeyProps): Chainable;
selection(
fn: (this: Cypress.ObjectLike, currentSubject: JQuery<any>) => Chainable,
): Chainable;
setSelection(
query:
| string
| {
anchorQuery: string;
anchorOffset?: number;
focusQuery: string;
focusOffset?: number;
},
endQuery: string,
): Chainable;
setCursor(query: string, atStart?: boolean): Chainable;
setCursorBefore(query: string): Chainable;
setCursorAfter(query: string): Chainable;
print(message: string): Chainable;
insertCodeBlock(): Chainable;
insertEditorComponent(title: string): Chainable;
clickToolbarButton(title: string, opts: { times: number }): Chainable;
clickHeadingOneButton(opts: { times: number }): Chainable;
clickHeadingTwoButton(opts: { times: number }): Chainable;
clickOrderedListButton(opts: { times: number }): Chainable;
clickUnorderedListButton(opts: { times: number }): Chainable;
clickCodeButton(opts: { times: number }): Chainable;
clickItalicButton(opts: { times: number }): Chainable;
clickQuoteButton(opts: { times: number }): Chainable;
clickLinkButton(opts: { times: number }): Chainable;
clickModeToggle(): Chainable;
}
}
}

View File

@ -0,0 +1,30 @@
import fixture from '../common/editorial_workflow';
import * as specUtils from '../common/spec_utils';
import { entry1, entry2, entry3 } from '../common/entries';
const backend = 'bitbucket';
describe('BitBucket Backend Editorial Workflow', () => {
let taskResult = { data: {} };
before(() => {
specUtils.before(taskResult, { publish_mode: 'editorial_workflow' }, backend);
});
after(() => {
specUtils.after(taskResult, backend);
});
beforeEach(() => {
specUtils.beforeEach(taskResult, backend);
});
afterEach(() => {
specUtils.afterEach(taskResult, backend);
});
fixture({
entries: [entry1, entry2, entry3],
getUser: () => taskResult.data.user,
});
});

View File

@ -0,0 +1,31 @@
import fixture from '../common/editorial_workflow';
import * as specUtils from '../common/spec_utils';
import { entry1, entry2, entry3 } from '../common/entries';
const backend = 'git-gateway';
const provider = 'github';
describe('Git Gateway (GitHub) Backend Editorial Workflow', () => {
let taskResult = { data: {} };
before(() => {
specUtils.before(taskResult, { publish_mode: 'editorial_workflow', provider }, backend);
});
after(() => {
specUtils.after(taskResult, backend);
});
beforeEach(() => {
specUtils.beforeEach(taskResult, backend);
});
afterEach(() => {
specUtils.afterEach(taskResult, backend);
});
fixture({
entries: [entry1, entry2, entry3],
getUser: () => taskResult.data.user,
});
});

View File

@ -0,0 +1,31 @@
import fixture from '../common/editorial_workflow';
import * as specUtils from '../common/spec_utils';
import { entry1, entry2, entry3 } from '../common/entries';
const backend = 'git-gateway';
const provider = 'gitlab';
describe('Git Gateway (GitLab) Backend Editorial Workflow', () => {
let taskResult = { data: {} };
before(() => {
specUtils.before(taskResult, { publish_mode: 'editorial_workflow', provider }, backend);
});
after(() => {
specUtils.after(taskResult, backend);
});
beforeEach(() => {
specUtils.beforeEach(taskResult, backend);
});
afterEach(() => {
specUtils.afterEach(taskResult, backend);
});
fixture({
entries: [entry1, entry2, entry3],
getUser: () => taskResult.data.user,
});
});

View File

@ -0,0 +1,37 @@
import fixture from '../common/editorial_workflow';
import * as specUtils from '../common/spec_utils';
import { entry1, entry2, entry3 } from '../common/entries';
const backend = 'github';
describe('Github Backend Editorial Workflow - GraphQL API', () => {
const taskResult = { data: {} };
before(() => {
specUtils.before(
taskResult,
{
backend: { use_graphql: true, open_authoring: false },
publish_mode: 'editorial_workflow',
},
backend,
);
});
after(() => {
specUtils.after(taskResult, backend);
});
beforeEach(() => {
specUtils.beforeEach(taskResult, backend);
});
afterEach(() => {
specUtils.afterEach(taskResult, backend);
});
fixture({
entries: [entry1, entry2, entry3],
getUser: () => taskResult.data.user,
});
});

View File

@ -0,0 +1,38 @@
import fixture from '../common/open_authoring';
import * as specUtils from '../common/spec_utils';
import { entry1, entry2, entry3 } from '../common/entries';
const backend = 'github';
describe('Github Backend Editorial Workflow - GraphQL API - Open Authoring', () => {
const taskResult = { data: {} };
before(() => {
specUtils.before(
taskResult,
{
backend: { use_graphql: true, open_authoring: true },
publish_mode: 'editorial_workflow',
},
backend,
);
});
after(() => {
specUtils.after(taskResult, backend);
});
beforeEach(() => {
specUtils.beforeEach(taskResult, backend);
});
afterEach(() => {
specUtils.afterEach(taskResult, backend);
});
fixture({
entries: [entry1, entry2, entry3],
getUser: () => taskResult.data.user,
getForkUser: () => taskResult.data.forkUser,
});
});

View File

@ -0,0 +1,37 @@
import fixture from '../common/editorial_workflow';
import * as specUtils from '../common/spec_utils';
import { entry1, entry2, entry3 } from '../common/entries';
const backend = 'github';
describe('Github Backend Editorial Workflow - REST API', () => {
let taskResult = { data: {} };
before(() => {
specUtils.before(
taskResult,
{
backend: { use_graphql: false, open_authoring: false },
publish_mode: 'editorial_workflow',
},
backend,
);
});
after(() => {
specUtils.after(taskResult, backend);
});
beforeEach(() => {
specUtils.beforeEach(taskResult, backend);
});
afterEach(() => {
specUtils.afterEach(taskResult, backend);
});
fixture({
entries: [entry1, entry2, entry3],
getUser: () => taskResult.data.user,
});
});

View File

@ -0,0 +1,38 @@
import fixture from '../common/open_authoring';
import * as specUtils from '../common/spec_utils';
import { entry1, entry2, entry3 } from '../common/entries';
const backend = 'github';
describe('Github Backend Editorial Workflow - REST API - Open Authoring', () => {
let taskResult = { data: {} };
before(() => {
specUtils.before(
taskResult,
{
backend: { use_graphql: false, open_authoring: true },
publish_mode: 'editorial_workflow',
},
backend,
);
});
after(() => {
specUtils.after(taskResult, backend);
});
beforeEach(() => {
specUtils.beforeEach(taskResult, backend);
});
afterEach(() => {
specUtils.afterEach(taskResult, backend);
});
fixture({
entries: [entry1, entry2, entry3],
getUser: () => taskResult.data.user,
getForkUser: () => taskResult.data.forkUser,
});
});

View File

@ -0,0 +1,36 @@
import fixture from '../common/editorial_workflow';
import * as specUtils from '../common/spec_utils';
import { entry1, entry2, entry3 } from '../common/entries';
const backend = 'gitlab';
describe('GitLab Backend Editorial Workflow', () => {
let taskResult = { data: {} };
before(() => {
specUtils.before(taskResult, { publish_mode: 'editorial_workflow' }, backend);
});
after(() => {
specUtils.after(taskResult, backend);
});
beforeEach(() => {
if (
Cypress.mocha.getRunner().suite.ctx.currentTest.title ===
'can change status on and publish multiple entries'
) {
Cypress.mocha.getRunner().suite.ctx.currentTest.skip();
}
specUtils.beforeEach(taskResult, backend);
});
afterEach(() => {
specUtils.afterEach(taskResult, backend);
});
fixture({
entries: [entry1, entry2, entry3],
getUser: () => taskResult.data.user,
});
});

View File

@ -0,0 +1,32 @@
import fixture from '../common/editorial_workflow';
import * as specUtils from '../common/spec_utils';
import { entry1, entry2, entry3 } from '../common/entries';
const backend = 'proxy';
const mode = 'git';
describe.skip(`Proxy Backend Editorial Workflow - '${mode}' mode`, () => {
let taskResult = { data: {} };
before(() => {
specUtils.before(taskResult, { publish_mode: 'editorial_workflow', mode }, backend);
Cypress.config('defaultCommandTimeout', 5 * 1000);
});
after(() => {
specUtils.after(taskResult, backend);
});
beforeEach(() => {
specUtils.beforeEach(taskResult, backend);
});
afterEach(() => {
specUtils.afterEach(taskResult, backend);
});
fixture({
entries: [entry1, entry2, entry3],
getUser: () => taskResult.data.user,
});
});

View File

@ -0,0 +1,54 @@
import {
login,
validateObjectFieldsAndExit,
validateNestedObjectFieldsAndExit,
validateListFieldsAndExit,
validateNestedListFieldsAndExit,
} from '../../utils/steps';
import { setting1, setting2 } from '../../utils/constants';
describe('Test Backend Editorial Workflow', () => {
after(() => {
cy.task('teardownBackend', { backend: 'test' });
});
before(() => {
Cypress.config('defaultCommandTimeout', 4000);
});
beforeEach(() => {
cy.task('setupBackend', { backend: 'test' });
});
it('can validate object fields', () => {
login({ editorialWorkflow: true });
cy.contains('a', 'Posts').click();
validateObjectFieldsAndExit(setting1);
});
it('can validate fields nested in an object field', () => {
login({ editorialWorkflow: true });
cy.contains('a', 'Posts').click();
validateNestedObjectFieldsAndExit(setting1);
});
it('can validate list fields', () => {
login({ editorialWorkflow: true });
cy.contains('a', 'Posts').click();
validateListFieldsAndExit(setting2);
});
it('can validate deeply nested list fields', () => {
login({ editorialWorkflow: true });
cy.contains('a', 'Posts').click();
validateNestedListFieldsAndExit(setting2);
});
});

View File

@ -0,0 +1,38 @@
import fixture from '../common/i18n_editorial_workflow_spec';
const backend = 'test';
describe(`I18N Test Backend Editorial Workflow`, () => {
const taskResult = { data: {} };
before(() => {
Cypress.config('defaultCommandTimeout', 4000);
cy.task('setupBackend', {
backend,
options: {
publish_mode: 'editorial_workflow',
i18n: {
locales: ['en', 'de', 'fr'],
},
collections: [
{
folder: 'content/i18n',
i18n: true,
fields: [{ i18n: true }, {}, { i18n: 'duplicate' }],
},
],
},
});
});
after(() => {
cy.task('teardownBackend', { backend });
});
const entry = {
Title: 'first title',
Body: 'first body',
};
fixture({ entry, getUser: () => taskResult.data.user });
});

View File

@ -0,0 +1,148 @@
import * as specUtils from '../common/spec_utils';
import { login } from '../../utils/steps';
import { createEntryTranslateAndPublish } from '../common/i18n';
const backend = 'proxy';
const mode = 'fs';
const expectedEnContent = `---
template: post
title: first title
date: 1970-01-01T00:00:00.000Z
description: first description
category: first category
tags:
- tag1
---
`;
const expectedDeContent = `---
title: de
date: 1970-01-01T00:00:00.000Z
---
`;
const expectedFrContent = `---
title: fr
date: 1970-01-01T00:00:00.000Z
---
`;
const contentSingleFile = `---
en:
template: post
date: 1970-01-01T00:00:00.000Z
title: first title
body: first body
description: first description
category: first category
tags:
- tag1
de:
date: 1970-01-01T00:00:00.000Z
title: de
fr:
date: 1970-01-01T00:00:00.000Z
title: fr
---
`;
describe(`I18N Proxy Backend Simple Workflow - '${mode}' mode`, () => {
const taskResult = { data: {} };
const entry = {
Title: 'first title',
Body: 'first body',
Description: 'first description',
Category: 'first category',
Tags: 'tag1',
};
before(() => {
specUtils.before(
taskResult,
{
mode,
publish_mode: 'simple',
i18n: {
locales: ['en', 'de', 'fr'],
},
collections: [{ i18n: true, fields: [{}, { i18n: true }, {}, { i18n: 'duplicate' }] }],
},
backend,
);
Cypress.config('taskTimeout', 15 * 1000);
Cypress.config('defaultCommandTimeout', 5 * 1000);
});
after(() => {
specUtils.after(taskResult, backend);
});
beforeEach(() => {
specUtils.beforeEach(taskResult, backend);
});
afterEach(() => {
specUtils.afterEach(taskResult, backend);
});
it('can create entry with translation in locale_folders mode', () => {
cy.task('updateConfig', { i18n: { structure: 'multiple_folders' } });
login({ user: taskResult.data.user });
createEntryTranslateAndPublish(entry);
cy.readFile(`${taskResult.data.tempDir}/content/posts/en/1970-01-01-first-title.md`).should(
'contain',
expectedEnContent,
);
cy.readFile(`${taskResult.data.tempDir}/content/posts/de/1970-01-01-first-title.md`).should(
'eq',
expectedDeContent,
);
cy.readFile(`${taskResult.data.tempDir}/content/posts/fr/1970-01-01-first-title.md`).should(
'eq',
expectedFrContent,
);
});
it('can create entry with translation in single_file mode', () => {
cy.task('updateConfig', { i18n: { structure: 'multiple_files' } });
login({ user: taskResult.data.user });
createEntryTranslateAndPublish(entry);
cy.readFile(`${taskResult.data.tempDir}/content/posts/1970-01-01-first-title.en.md`).should(
'contain',
expectedEnContent,
);
cy.readFile(`${taskResult.data.tempDir}/content/posts/1970-01-01-first-title.de.md`).should(
'eq',
expectedDeContent,
);
cy.readFile(`${taskResult.data.tempDir}/content/posts/1970-01-01-first-title.fr.md`).should(
'eq',
expectedFrContent,
);
});
it('can create entry with translation in locale_file_extensions mode', () => {
cy.task('updateConfig', { i18n: { structure: 'single_file' } });
login({ user: taskResult.data.user });
createEntryTranslateAndPublish(entry);
cy.readFile(`${taskResult.data.tempDir}/content/posts/1970-01-01-first-title.md`).should(
'eq',
contentSingleFile,
);
});
});

View File

@ -0,0 +1,76 @@
describe('Markdown widget', () => {
before(() => {
Cypress.config('defaultCommandTimeout', 4000);
cy.task('setupBackend', { backend: 'test' });
});
beforeEach(() => {
cy.loginAndNewPost();
cy.clearMarkdownEditorContent();
});
after(() => {
cy.task('teardownBackend', { backend: 'test' });
});
// describe('pressing backspace', () => {
it('sets non-default block to default when empty', () => {
cy.focused().clickHeadingOneButton().backspace().confirmMarkdownEditorContent(`
<p></p>
`);
});
it('moves to previous block when no character left to delete', () => {
cy.focused().type('foo').enter().clickHeadingOneButton().type('a').backspace({ times: 2 })
.confirmMarkdownEditorContent(`
<p>foo</p>
`);
});
it('does nothing at start of first block in document when non-empty and non-default', () => {
cy.focused().clickHeadingOneButton().type('foo').setCursorBefore('foo').backspace({ times: 4 })
.confirmMarkdownEditorContent(`
<h1>foo</h1>
`);
});
it('deletes individual characters in middle of non-empty non-default block in document', () => {
cy.focused().clickHeadingOneButton().type('foo').setCursorAfter('fo').backspace({ times: 3 })
.confirmMarkdownEditorContent(`
<h1>o</h1>
`);
});
it('at beginning of non-first block, moves default block content to previous block', () => {
cy
.focused()
.clickHeadingOneButton()
.type('foo')
.enter()
.type('bar')
.setCursorBefore('bar')
.backspace().confirmMarkdownEditorContent(`
<h1>foobar</h1>
`);
});
it('at beginning of non-first block, moves non-default block content to previous block', () => {
cy
.focused()
.type('foo')
.enter()
.clickHeadingOneButton()
.type('bar')
.enter()
.clickHeadingTwoButton()
.type('baz')
.setCursorBefore('baz')
.backspace()
.confirmMarkdownEditorContent(
`
<p>foo</p>
<h1>barbaz</h1>
`,
)
.setCursorBefore('bar')
.backspace().confirmMarkdownEditorContent(`
<p>foobarbaz</p>
`);
// });
});
});

View File

@ -0,0 +1,134 @@
import { oneLineTrim, stripIndent } from 'common-tags';
describe('Markdown widget code block', () => {
before(() => {
Cypress.config('defaultCommandTimeout', 4000);
cy.task('setupBackend', { backend: 'test' });
});
beforeEach(() => {
cy.loginAndNewPost();
cy.clearMarkdownEditorContent();
});
after(() => {
cy.task('teardownBackend', { backend: 'test' });
});
describe('code block', () => {
it('outputs code', () => {
// eslint-disable-next-line cypress/no-unnecessary-waiting
cy
.insertCodeBlock()
.type('foo')
.enter()
.type('bar')
.confirmMarkdownEditorContent(
`
${codeBlock(`
foo
bar
`)}
`,
)
.wait(500)
.clickModeToggle().confirmMarkdownEditorContent(`
${codeBlockRaw(`
foo
bar
`)}
`);
});
});
});
function codeBlockRaw(content) {
return ['```', ...stripIndent(content).split('\n'), '```']
.map(
line => oneLineTrim`
<div>
<span>
<span>
<span>${line}</span>
</span>
</span>
</div>
`,
)
.join('');
}
function codeBlock(content) {
const lines = stripIndent(content)
.split('\n')
.map(
(line, idx) => `
<div>
<div>
<div>${idx + 1}</div>
</div>
<pre><span>${line}</span></pre>
</div>
`,
)
.join('');
return oneLineTrim`
<div>
<div></div>
<div>
<div><label>Code Block </label>
<div><button><span><svg>
<path></path>
</svg></span></button>
<div>
<div>
<div><textarea></textarea></div>
<div>
<div></div>
</div>
<div>
<div></div>
</div>
<div></div>
<div></div>
<div>
<div>
<div>
<div>
<div>
<div>
<pre><span>xxxxxxxxxx</span></pre>
</div>
<div></div>
<div></div>
<div>
<div> </div>
</div>
<div>
${lines}
</div>
</div>
</div>
</div>
</div>
<div></div>
<div>
<div></div>
</div>
</div>
</div>
</div>
</div>
</div>
<div>
<span>
<span>
<span></span>
</span>
</span>
</div>
</div>
<div></div>
</div>
`;
}

View File

@ -0,0 +1,80 @@
describe('Markdown widget breaks', () => {
before(() => {
Cypress.config('defaultCommandTimeout', 4000);
cy.task('setupBackend', { backend: 'test' });
});
beforeEach(() => {
cy.loginAndNewPost();
cy.clearMarkdownEditorContent();
});
after(() => {
cy.task('teardownBackend', { backend: 'test' });
});
describe('pressing enter', () => {
it('creates new default block from empty block', () => {
cy.focused().enter().confirmMarkdownEditorContent(`
<p></p>
<p></p>
`);
});
it('creates new default block when selection collapsed at end of block', () => {
cy.focused().type('foo').enter().confirmMarkdownEditorContent(`
<p>foo</p>
<p></p>
`);
});
it('creates new default block when selection collapsed at end of non-default block', () => {
cy.clickHeadingOneButton().type('foo').enter().confirmMarkdownEditorContent(`
<h1>foo</h1>
<p></p>
`);
});
it('creates new default block when selection collapsed in empty non-default block', () => {
cy.clickHeadingOneButton().enter().confirmMarkdownEditorContent(`
<h1></h1>
<p></p>
`);
});
it('splits block into two same-type blocks when collapsed selection at block start', () => {
cy.clickHeadingOneButton().type('foo').setCursorBefore('foo').enter()
.confirmMarkdownEditorContent(`
<h1></h1>
<h1>foo</h1>
`);
});
it('splits block into two same-type blocks when collapsed in middle of selection at block start', () => {
cy.clickHeadingOneButton().type('foo').setCursorBefore('oo').enter()
.confirmMarkdownEditorContent(`
<h1>f</h1>
<h1>oo</h1>
`);
});
it('deletes selected content and splits to same-type block when selection is expanded', () => {
cy.clickHeadingOneButton().type('foo bar').setSelection('o b').enter()
.confirmMarkdownEditorContent(`
<h1>fo</h1>
<h1>ar</h1>
`);
});
});
describe('pressing shift+enter', () => {
it('creates line break', () => {
cy.focused().enter({ shift: true }).confirmMarkdownEditorContent(`
<p>
</p>
`);
});
it('creates consecutive line break', () => {
cy.focused().enter({ shift: true, times: 4 }).confirmMarkdownEditorContent(`
<p>
</p>
`);
});
});
});

View File

@ -0,0 +1,109 @@
import { HOT_KEY_MAP } from '../../utils/constants';
const headingNumberToWord = ['', 'one', 'two', 'three', 'four', 'five', 'six'];
const isMac = Cypress.platform === 'darwin';
const modifierKey = isMac ? '{meta}' : '{ctrl}';
// eslint-disable-next-line func-style
const replaceMod = str => str.replace(/mod\+/g, modifierKey).replace(/shift\+/g, '{shift}');
describe('Markdown widget hotkeys', () => {
describe('hot keys', () => {
before(() => {
Cypress.config('defaultCommandTimeout', 4000);
cy.task('setupBackend', { backend: 'test' });
});
beforeEach(() => {
cy.loginAndNewPost();
cy.clearMarkdownEditorContent();
cy.focused().type('foo').setSelection('foo').as('selection');
});
after(() => {
cy.task('teardownBackend', { backend: 'test' });
});
describe('bold', () => {
it('pressing mod+b bolds the text', () => {
cy.get('@selection')
.type(replaceMod(HOT_KEY_MAP['bold']))
.confirmMarkdownEditorContent(
`
<p>
<strong>foo</strong>
</p>
`,
)
.type(replaceMod(HOT_KEY_MAP['bold']));
});
});
describe('italic', () => {
it('pressing mod+i italicizes the text', () => {
cy.get('@selection')
.type(replaceMod(HOT_KEY_MAP['italic']))
.confirmMarkdownEditorContent(
`
<p>
<em>foo</em>
</p>
`,
)
.type(replaceMod(HOT_KEY_MAP['italic']));
});
});
describe('strikethrough', () => {
it('pressing mod+shift+s displays a strike through the text', () => {
cy.get('@selection')
.type(replaceMod(HOT_KEY_MAP['strikethrough']))
.confirmMarkdownEditorContent(
`
<p>
<s>foo</s>
</p>
`,
)
.type(replaceMod(HOT_KEY_MAP['strikethrough']));
});
});
describe('code', () => {
it('pressing mod+shift+c displays a code block around the text', () => {
cy.get('@selection')
.type(replaceMod(HOT_KEY_MAP['code']))
.confirmMarkdownEditorContent(
`
<p>
<code>foo</code>
</p>
`,
)
.type(replaceMod(HOT_KEY_MAP['code']));
});
});
describe('link', () => {
before(() => {});
it('pressing mod+k transforms the text to a link', () => {
cy.window().then(win => {
cy.get('@selection').type(replaceMod(HOT_KEY_MAP['link']));
cy.stub(win, 'prompt').returns('https://google.com');
cy.confirmMarkdownEditorContent('<p><a>foo</a></p>').type(
replaceMod(HOT_KEY_MAP['link']),
);
});
});
});
describe('headings', () => {
for (let i = 1; i <= 6; i++) {
it(`pressing mod+${i} transforms the text to a heading`, () => {
cy.get('@selection')
.type(replaceMod(HOT_KEY_MAP[`heading-${headingNumberToWord[i]}`]))
.confirmMarkdownEditorContent(`<h${i}>foo</h${i}>`)
.type(replaceMod(HOT_KEY_MAP[`heading-${headingNumberToWord[i]}`]));
});
}
});
});
});

View File

@ -0,0 +1,64 @@
describe('Markdown widget link', () => {
before(() => {
Cypress.config('defaultCommandTimeout', 4000);
cy.task('setupBackend', { backend: 'test' });
});
beforeEach(() => {
cy.loginAndNewPost();
cy.clearMarkdownEditorContent();
});
after(() => {
cy.task('teardownBackend', { backend: 'test' });
});
describe('link', () => {
it('can add a new valid link', () => {
const link = 'https://www.staticcms.org/';
cy.window().then(win => {
cy.stub(win, 'prompt').returns(link);
});
cy.focused().clickLinkButton();
cy.confirmMarkdownEditorContent(`<p><a>${link}</a></p>`);
// eslint-disable-next-line cypress/no-unnecessary-waiting
cy.wait(300);
cy.clickModeToggle();
cy.confirmRawEditorContent(`<${link}>`);
});
it('can add a new invalid link', () => {
const link = 'www.staticcms.org';
cy.window().then(win => {
cy.stub(win, 'prompt').returns(link);
});
cy.focused().clickLinkButton();
cy.confirmMarkdownEditorContent(`<p><a>${link}</a></p>`);
// eslint-disable-next-line cypress/no-unnecessary-waiting
cy.wait(300);
cy.clickModeToggle();
cy.confirmRawEditorContent(`[${link}](${link})`);
});
it('can select existing text as link', () => {
const link = 'https://www.staticcms.org';
cy.window().then(win => {
cy.stub(win, 'prompt').returns(link);
});
const text = 'Static CMS';
cy.focused().getMarkdownEditor().type(text).setSelection(text).clickLinkButton();
cy.confirmMarkdownEditorContent(`<p><a>${text}</a></p>`);
// eslint-disable-next-line cypress/no-unnecessary-waiting
cy.wait(300);
cy.clickModeToggle();
cy.confirmRawEditorContent(`[${text}](${link})`);
});
});
});

View File

@ -0,0 +1,734 @@
describe('Markdown widget', () => {
describe('list', () => {
before(() => {
Cypress.config('defaultCommandTimeout', 4000);
cy.task('setupBackend', { backend: 'test' });
});
beforeEach(() => {
cy.loginAndNewPost();
cy.clearMarkdownEditorContent();
});
after(() => {
cy.task('teardownBackend', { backend: 'test' });
});
// describe('toolbar buttons', () => {
it('creates and focuses empty list', () => {
cy.clickUnorderedListButton().confirmMarkdownEditorContent(`
<ul>
<li>
<p></p>
</li>
</ul>
`);
});
it('removes list', () => {
cy.clickUnorderedListButton().clickUnorderedListButton().confirmMarkdownEditorContent(`
<p></p>
`);
});
it('converts a list item to a paragraph block which is a sibling of the parent list', () => {
cy.clickUnorderedListButton().type('foo').enter().clickUnorderedListButton()
.confirmMarkdownEditorContent(`
<ul>
<li>
<p>foo</p>
</li>
</ul>
<p></p>
`);
});
it('converts empty nested list item to empty paragraph block in parent list item', () => {
cy
.clickUnorderedListButton()
.type('foo')
.enter()
.tabkey()
.type('bar')
.enter()
.tabkey()
.confirmMarkdownEditorContent(
`
<ul>
<li>
<p>foo</p>
<ul>
<li>
<p>bar</p>
<ul>
<li>
<p></p>
</li>
</ul>
</li>
</ul>
</li>
</ul>
`,
)
.clickUnorderedListButton()
.confirmMarkdownEditorContent(
`
<ul>
<li>
<p>foo</p>
<ul>
<li>
<p>bar</p>
<p></p>
</li>
</ul>
</li>
</ul>
`,
)
.backspace({ times: 4 })
.clickUnorderedListButton().confirmMarkdownEditorContent(`
<ul>
<li>
<p>foo</p>
<p></p>
</li>
</ul>
`);
});
it('moves nested list item content to parent list item when in first block', () => {
cy
.clickUnorderedListButton()
.type('foo')
.enter()
.tabkey()
.type('bar')
.enter()
.tabkey()
.type('baz')
.clickUnorderedListButton()
.confirmMarkdownEditorContent(
`
<ul>
<li>
<p>foo</p>
<ul>
<li>
<p>bar</p>
<p>baz</p>
</li>
</ul>
</li>
</ul>
`,
)
.up()
.clickUnorderedListButton()
.confirmMarkdownEditorContent(
`
<ul>
<li>
<p>foo</p>
<p>bar</p>
<p>baz</p>
</li>
</ul>
`,
)
.up()
.clickUnorderedListButton().confirmMarkdownEditorContent(`
<p>foo</p>
<p>bar</p>
<p>baz</p>
`);
});
it('affects only the current block with collapsed selection', () => {
cy
.focused()
.type('foo')
.enter()
.type('bar')
.enter()
.type('baz')
.up()
.clickUnorderedListButton().confirmMarkdownEditorContent(`
<p>foo</p>
<ul>
<li>
<p>bar</p>
</li>
</ul>
<p>baz</p>
`);
});
it('wrap each bottom-most block in a selection with a list item block', () => {
// eslint-disable-next-line cypress/no-unnecessary-waiting
cy
.focused()
.type('foo')
.enter()
.type('bar')
.enter()
.type('baz')
.setSelection('foo', 'baz')
.wait(500)
.clickUnorderedListButton().confirmMarkdownEditorContent(`
<ul>
<li>
<p>foo</p>
</li>
<li>
<p>bar</p>
</li>
<li>
<p>baz</p>
</li>
</ul>
`);
});
it('unwraps list item block from each selected list item and unwraps all of them from the outer list block', () => {
// eslint-disable-next-line cypress/no-unnecessary-waiting
cy
.clickUnorderedListButton()
.type('foo')
.enter()
.type('bar')
.enter()
.type('baz')
.setSelection('foo', 'baz')
.wait(500)
.clickUnorderedListButton().confirmMarkdownEditorContent(`
<p>foo</p>
<p>bar</p>
<p>baz</p>
`);
});
it('combines adjacent same-typed lists, not differently typed lists', () => {
cy.focused()
.type('foo')
.enter()
.type('bar')
.enter()
.type('baz')
.up()
.clickUnorderedListButton()
.up()
.clickUnorderedListButton()
.confirmMarkdownEditorContent(
`
<ul>
<li>
<p>foo</p>
</li>
<li>
<p>bar</p>
</li>
</ul>
<p>baz</p>
`,
)
.down({ times: 2 })
.focused()
.clickUnorderedListButton()
.confirmMarkdownEditorContent(
`
<ul>
<li>
<p>foo</p>
</li>
<li>
<p>bar</p>
</li>
<li>
<p>baz</p>
</li>
</ul>
`,
)
.up()
.enter()
.type('qux')
.tabkey()
.confirmMarkdownEditorContent(
`
<ul>
<li>
<p>foo</p>
</li>
<li>
<p>bar</p>
<ul>
<li>
<p>qux</p>
</li>
</ul>
</li>
<li>
<p>baz</p>
</li>
</ul>
`,
)
.up()
.enter()
.type('quux')
.confirmMarkdownEditorContent(
`
<ul>
<li>
<p>foo</p>
</li>
<li>
<p>bar</p>
<ul>
<li>
<p>quux</p>
</li>
<li>
<p>qux</p>
</li>
</ul>
</li>
<li>
<p>baz</p>
</li>
</ul>
`,
)
.clickOrderedListButton()
.confirmMarkdownEditorContent(
`
<ul>
<li>
<p>foo</p>
</li>
<li>
<p>bar</p>
<ol>
<li>
<p>quux</p>
</li>
</ol>
<ul>
<li>
<p>qux</p>
</li>
</ul>
</li>
<li>
<p>baz</p>
</li>
</ul>
`,
)
.setSelection({
anchorQuery: 'ul > li > ol p',
anchorOffset: 1,
focusQuery: 'ul > li > ul:last-child p',
focusOffset: 2,
});
});
// while this works on dev environemnt, it will always fail in cypress - has something to do with text selection
// it('affects only selected list items', () => {
// cy
// .clickUnorderedListButton()
// .type('foo')
// .enter()
// .type('bar')
// .enter()
// .type('baz')
// .setSelection('bar')
// .clickUnorderedListButton()
// .confirmMarkdownEditorContent(
// `
// <ul>
// <li>
// <p>foo</p>
// </li>
// </ul>
// <p>bar</p>
// <ul>
// <li>
// <p>baz</p>
// </li>
// </ul>
// `,
// )
// .clickUnorderedListButton()
// .setSelection('bar', 'baz')
// .clickUnorderedListButton()
// .confirmMarkdownEditorContent(
// `
// <ul>
// <li>
// <p>foo</p>
// </li>
// </ul>
// <p>bar</p>
// <p>baz</p>
// `,
// )
// .clickUnorderedListButton()
// .confirmMarkdownEditorContent(
// `
// <ul>
// <li>
// <p>foo</p>
// </li>
// <li>
// <p>bar</p>
// </li>
// <li>
// <p>baz</p>
// </li>
// </ul>
// `,
// )
// .setSelection('baz')
// .clickUnorderedListButton()
// .confirmMarkdownEditorContent(
// `
// <ul>
// <li>
// <p>foo</p>
// </li>
// <li>
// <p>bar</p>
// </li>
// </ul>
// <p>baz</p>
// `,
// )
// .clickUnorderedListButton()
// .tabkey()
// .setCursorAfter('baz')
// .enter()
// .tabkey()
// .type('qux')
// .confirmMarkdownEditorContent(
// `
// <ul>
// <li>
// <p>foo</p>
// </li>
// <li>
// <p>bar</p>
// <ul>
// <li>
// <p>baz</p>
// <ul>
// <li>
// <p>qux</p>
// </li>
// </ul>
// </li>
// </ul>
// </li>
// </ul>
// `,
// )
// .setSelection('baz')
// .clickOrderedListButton()
// .confirmMarkdownEditorContent(
// `
// <ul>
// <li>
// <p>foo</p>
// </li>
// <li>
// <p>bar</p>
// <ol>
// <li>
// <p>baz</p>
// <ul>
// <li>
// <p>qux</p>
// </li>
// </ul>
// </li>
// </ol>
// </li>
// </ul>
// `,
// )
// .setCursorAfter('qux')
// .enter({ times: 2 })
// .clickUnorderedListButton()
// .confirmMarkdownEditorContent(`
// <ul>
// <li>
// <p>foo</p>
// </li>
// <li>
// <p>bar</p>
// <ol>
// <li>
// <p>baz</p>
// <ul>
// <li>
// <p>qux</p>
// </li>
// </ul>
// </li>
// </ol>
// <ul>
// <li>
// <p></p>
// </li>
// </ul>
// </li>
// </ul>
// `);
// });
// });
// });
// describe('on Enter', () => {
it('removes the list item and list if empty', () => {
cy.clickUnorderedListButton().enter().confirmMarkdownEditorContent(`
<p></p>
`);
});
it('creates a new list item in a non-empty list', () => {
cy
.clickUnorderedListButton()
.type('foo')
.enter()
.confirmMarkdownEditorContent(
`
<ul>
<li>
<p>foo</p>
</li>
<li>
<p></p>
</li>
</ul>
`,
)
.type('bar')
.enter().confirmMarkdownEditorContent(`
<ul>
<li>
<p>foo</p>
</li>
<li>
<p>bar</p>
</li>
<li>
<p></p>
</li>
</ul>
`);
});
it('creates a new default block below a list when hitting Enter twice on an empty list item of the list', () => {
cy.clickUnorderedListButton().type('foo').enter({ times: 2 }).confirmMarkdownEditorContent(`
<ul>
<li>
<p>foo</p>
</li>
</ul>
<p></p>
`);
});
// });
// describe('on Backspace', () => {
it('removes the list item and list if empty', () => {
cy.clickUnorderedListButton().backspace().confirmMarkdownEditorContent(`
<p></p>
`);
});
it('removes the list item if list not empty', () => {
cy.clickUnorderedListButton().type('foo').enter().backspace().confirmMarkdownEditorContent(`
<ul>
<li>
<p>foo</p>
<p></p>
</li>
</ul>
`);
});
it('does not remove list item if empty with non-default block', () => {
cy.clickUnorderedListButton().clickHeadingOneButton().backspace()
.confirmMarkdownEditorContent(`
<ul>
<li>
<p></p>
</li>
</ul>
`);
});
// });
// describe('on Tab', () => {
it('does nothing in top level list', () => {
cy
.clickUnorderedListButton()
.tabkey()
.confirmMarkdownEditorContent(
`
<ul>
<li>
<p></p>
</li>
</ul>
`,
)
.type('foo')
.tabkey().confirmMarkdownEditorContent(`
<ul>
<li>
<p>foo</p>
</li>
</ul>
`);
});
it('indents nested list items', () => {
cy
.clickUnorderedListButton()
.type('foo')
.enter()
.type('bar')
.tabkey()
.confirmMarkdownEditorContent(
`
<ul>
<li>
<p>foo</p>
<ul>
<li>
<p>bar</p>
</li>
</ul>
</li>
</ul>
`,
)
.enter()
.tabkey().confirmMarkdownEditorContent(`
<ul>
<li>
<p>foo</p>
<ul>
<li>
<p>bar</p>
<ul>
<li>
<p></p>
</li>
</ul>
</li>
</ul>
</li>
</ul>
`);
});
it('only nests up to one level down from the parent list', () => {
cy.clickUnorderedListButton().type('foo').enter().tabkey().confirmMarkdownEditorContent(`
<ul>
<li>
<p>foo</p>
<ul>
<li>
<p></p>
</li>
</ul>
</li>
</ul>
`);
});
it('unindents nested list items with shift', () => {
cy.clickUnorderedListButton().type('foo').enter().tabkey().tabkey({ shift: true })
.confirmMarkdownEditorContent(`
<ul>
<li>
<p>foo</p>
</li>
<li>
<p></p>
</li>
</ul>
`);
});
it('indents and unindents from one level below parent back to document root', () => {
cy
.clickUnorderedListButton()
.type('foo')
.enter()
.tabkey()
.type('bar')
.enter()
.tabkey()
.type('baz')
.confirmMarkdownEditorContent(
`
<ul>
<li>
<p>foo</p>
<ul>
<li>
<p>bar</p>
<ul>
<li>
<p>baz</p>
</li>
</ul>
</li>
</ul>
</li>
</ul>
`,
)
.tabkey({ shift: true })
.confirmMarkdownEditorContent(
`
<ul>
<li>
<p>foo</p>
<ul>
<li>
<p>bar</p>
</li>
<li>
<p>baz</p>
</li>
</ul>
</li>
</ul>
`,
)
.tabkey({ shift: true }).confirmMarkdownEditorContent(`
<ul>
<li>
<p>foo</p>
<ul>
<li>
<p>bar</p>
</li>
</ul>
</li>
<li>
<p>baz</p>
</li>
</ul>
`);
});
// });
});
});

View File

@ -0,0 +1,31 @@
describe('Markdown widget', () => {
describe('code mark', () => {
before(() => {
Cypress.config('defaultCommandTimeout', 4000);
cy.task('setupBackend', { backend: 'test' });
});
beforeEach(() => {
cy.loginAndNewPost();
cy.clearMarkdownEditorContent();
});
after(() => {
cy.task('teardownBackend', { backend: 'test' });
});
describe('toolbar button', () => {
it('can combine code mark with other marks', () => {
cy.clickItalicButton().type('foo').setSelection('oo').clickCodeButton()
.confirmMarkdownEditorContent(`
<p>
<em>f</em>
<code>
<em>oo</em>
</code>
</p>
`);
});
});
});
});

View File

@ -0,0 +1,370 @@
describe('Markdown widget', () => {
describe('quote block', () => {
before(() => {
Cypress.config('defaultCommandTimeout', 4000);
cy.task('setupBackend', { backend: 'test' });
});
beforeEach(() => {
cy.loginAndNewPost();
cy.clearMarkdownEditorContent();
});
after(() => {
cy.task('teardownBackend', { backend: 'test' });
});
// describe('toggle quote', () => {
it('toggles empty quote block on and off in empty editor', () => {
cy
.clickQuoteButton()
.confirmMarkdownEditorContent(
`
<blockquote>
<p></p>
</blockquote>
`,
)
.clickQuoteButton().confirmMarkdownEditorContent(`
<p></p>
`);
});
it('toggles empty quote block on and off for current block', () => {
cy
.focused()
.type('foo')
.clickQuoteButton()
.confirmMarkdownEditorContent(
`
<blockquote>
<p>foo</p>
</blockquote>
`,
)
.clickQuoteButton().confirmMarkdownEditorContent(`
<p>foo</p>
`);
});
it('toggles entire quote block without expanded selection', () => {
cy.clickQuoteButton().type('foo').enter().type('bar').clickQuoteButton()
.confirmMarkdownEditorContent(`
<p>foo</p>
<p>bar</p>
`);
});
it('toggles entire quote block with complex content', () => {
cy
.clickQuoteButton()
.clickUnorderedListButton()
.clickHeadingOneButton()
.type('foo')
.enter({ times: 2 }) // First Enter creates new list item. Second Enter turns that list item into a default block.
.clickQuoteButton() // Unwrap the quote block.
.confirmMarkdownEditorContent(`
<ul>
<li>
<h1>foo</h1>
</li>
</ul>
<p></p>
`);
});
it('toggles empty quote block on and off for selected blocks', () => {
// eslint-disable-next-line cypress/no-unnecessary-waiting
cy
.focused()
.type('foo')
.enter()
.type('bar')
.setSelection('foo', 'bar')
.wait(500)
.clickQuoteButton()
.confirmMarkdownEditorContent(
`
<blockquote>
<p>foo</p>
<p>bar</p>
</blockquote>
`,
)
.clickQuoteButton()
.confirmMarkdownEditorContent(
`
<p>foo</p>
<p>bar</p>
`,
)
.clickQuoteButton().confirmMarkdownEditorContent(`
<blockquote>
<p>foo</p>
<p>bar</p>
</blockquote>
`);
});
it('toggles empty quote block on and off for partially selected blocks', () => {
// eslint-disable-next-line cypress/no-unnecessary-waiting
cy
.focused()
.type('foo')
.enter()
.type('bar')
.setSelection('oo', 'ba')
.wait(500)
.clickQuoteButton()
.confirmMarkdownEditorContent(
`
<blockquote>
<p>foo</p>
<p>bar</p>
</blockquote>
`,
)
.clickQuoteButton()
.confirmMarkdownEditorContent(
`
<p>foo</p>
<p>bar</p>
`,
)
.clickQuoteButton().confirmMarkdownEditorContent(`
<blockquote>
<p>foo</p>
<p>bar</p>
</blockquote>
`);
});
it('toggles quote block on and off for multiple selected list items', () => {
// eslint-disable-next-line cypress/no-unnecessary-waiting
cy
.focused()
.clickUnorderedListButton()
.type('foo')
.enter()
.type('bar')
.setSelection('foo', 'bar')
.wait(500)
.clickQuoteButton()
.confirmMarkdownEditorContent(
`
<blockquote>
<ul>
<li>
<p>foo</p>
</li>
<li>
<p>bar</p>
</li>
</ul>
</blockquote>
`,
)
.clickQuoteButton()
.confirmMarkdownEditorContent(
`
<ul>
<li>
<p>foo</p>
</li>
<li>
<p>bar</p>
</li>
</ul>
`,
)
.setCursorAfter('bar')
.wait(500)
.enter()
.type('baz')
.setSelection('bar', 'baz')
.wait(500)
.clickQuoteButton().confirmMarkdownEditorContent(`
<ul>
<li>
<p>foo</p>
</li>
</ul>
<blockquote>
<ul>
<li>
<p>bar</p>
</li>
<li>
<p>baz</p>
</li>
</ul>
</blockquote>
`);
});
it('creates new quote block if parent is not a quote, can deeply nest', () => {
cy.clickQuoteButton()
.clickUnorderedListButton()
.clickQuoteButton()
.clickUnorderedListButton()
.clickQuoteButton()
.clickUnorderedListButton()
.clickQuoteButton()
.type('foo')
// Content should contains 4 <blockquote> tags and 3 <ul> tags
.confirmMarkdownEditorContent(
`
<blockquote>
<ul>
<li>
<blockquote>
<ul>
<li>
<blockquote>
<ul>
<li>
<blockquote>
<p>foo</p>
</blockquote>
</li>
</ul>
</blockquote>
</li>
</ul>
</blockquote>
</li>
</ul>
</blockquote>
`,
)
/*
* First Enter creates new paragraph within the innermost block quote.
* Second Enter moves that paragraph one level up to become sibling of the previous quote block and direct child of a list item.
* Third Enter to turn that paragraph into a list item and move it one level up.
* Repeat the circle for three more times to reach the second list item of the outermost list block.
* Then Enter again to turn that list item into a paragraph and move it one level up to become sibling of the outermost list and
* direct child of the outermost block quote.
*/
.enter({ times: 10 })
.type('bar')
.confirmMarkdownEditorContent(
`
<blockquote>
<ul>
<li>
<blockquote>
<ul>
<li>
<blockquote>
<ul>
<li>
<blockquote>
<p>foo</p>
</blockquote>
</li>
</ul>
</blockquote>
</li>
</ul>
</blockquote>
</li>
</ul>
<p>bar</p>
</blockquote>
`,
)
/* The GOAL is to delete all the text content inside this deeply nested block quote and turn it into a default paragraph block on top level.
* We need:
* 3 Backspace to delete the word “bar”.
* 1 Backspace to remove the paragraph that contains bar and bring cursor to the end of the unordered list which is direct child of the outermost block quote.
* 3 Backspace to remove the word “foo”.
* 1 Backspace to remove the current block quote that the cursor is on, 1 Backspace to remove the list that wraps the block quote. Repeat this step for three times for a total of 6 Backspace until the cursor is on the outermost block quote.
* 1 Backspace to remove to toggle off the outermost block quote and turn it into a default paragraph.
* Total Backspaces required: 3 + 1 + 3 + ((1 + 1) * 3) + 1 = 14
*/
.backspace({ times: 14 });
});
// });
// describe('backspace inside quote', () => {
it('joins two paragraphs', () => {
cy.clickQuoteButton().type('foo').enter().type('bar').setCursorBefore('bar').backspace()
.confirmMarkdownEditorContent(`
<blockquote>
<p>foobar</p>
</blockquote>
`);
});
it('joins quote with previous quote', () => {
cy
.clickQuoteButton()
.type('foo')
.enter({ times: 2 })
.clickQuoteButton()
.type('bar')
.confirmMarkdownEditorContent(
`
<blockquote>
<p>foo</p>
</blockquote>
<blockquote>
<p>bar</p>
</blockquote>
`,
)
.setCursorBefore('bar')
.backspace().confirmMarkdownEditorContent(`
<blockquote>
<p>foo</p>
<p>bar</p>
</blockquote>
`);
});
it('removes first block from quote when focused at first block at start', () => {
// eslint-disable-next-line cypress/no-unnecessary-waiting
cy
.clickQuoteButton()
.type('foo')
.enter()
.type('bar')
.setCursorBefore('foo')
.wait(500)
.backspace().confirmMarkdownEditorContent(`
<p>foo</p>
<blockquote>
<p>bar</p>
</blockquote>
`);
});
// });
// describe('enter inside quote', () => {
it('creates new block inside quote', () => {
// eslint-disable-next-line cypress/no-unnecessary-waiting
cy
.clickQuoteButton()
.type('foo')
.enter()
.confirmMarkdownEditorContent(
`
<blockquote>
<p>foo</p>
<p></p>
</blockquote>
`,
)
.type('bar')
.setCursorAfter('ba')
.wait(500)
.enter().confirmMarkdownEditorContent(`
<blockquote>
<p>foo</p>
<p>ba</p>
<p>r</p>
</blockquote>
`);
});
it('creates new block after quote from empty last block', () => {
cy.clickQuoteButton().type('foo').enter().enter().confirmMarkdownEditorContent(`
<blockquote>
<p>foo</p>
</blockquote>
<p></p>
`);
});
// });
});
});

View File

@ -0,0 +1,27 @@
import fixture from '../common/media_library';
import { entry1 } from '../common/entries';
import * as specUtils from '../common/spec_utils';
const backend = 'bitbucket';
describe('BitBucket Backend Media Library - REST API', () => {
let taskResult = { data: {} };
before(() => {
specUtils.before(taskResult, {}, backend);
});
after(() => {
specUtils.after(taskResult, backend);
});
beforeEach(() => {
specUtils.beforeEach(taskResult, backend);
});
afterEach(() => {
specUtils.afterEach(taskResult, backend);
});
fixture({ entries: [entry1], getUser: () => taskResult.data.user });
});

View File

@ -0,0 +1,28 @@
import fixture from '../common/media_library';
import { entry1 } from '../common/entries';
import * as specUtils from '../common/spec_utils';
const backend = 'bitbucket';
const lfs = true;
describe('BitBucket Backend Media Library - Large Media', () => {
let taskResult = { data: {} };
before(() => {
specUtils.before(taskResult, { lfs }, backend);
});
after(() => {
specUtils.after(taskResult, backend);
});
beforeEach(() => {
specUtils.beforeEach(taskResult, backend);
});
afterEach(() => {
specUtils.afterEach(taskResult, backend);
});
fixture({ entries: [entry1], getUser: () => taskResult.data.user });
});

View File

@ -0,0 +1,28 @@
import fixture from '../common/media_library';
import { entry1 } from '../common/entries';
import * as specUtils from '../common/spec_utils';
const backend = 'git-gateway';
const provider = 'github';
describe('Git Gateway (GitHub) Backend Media Library - Large Media', () => {
let taskResult = { data: {} };
before(() => {
specUtils.before(taskResult, { publish_mode: 'editorial_workflow', provider }, backend);
});
after(() => {
specUtils.after(taskResult, backend);
});
beforeEach(() => {
specUtils.beforeEach(taskResult, backend);
});
afterEach(() => {
specUtils.afterEach(taskResult, backend);
});
fixture({ entries: [entry1], getUser: () => taskResult.data.user });
});

View File

@ -0,0 +1,28 @@
import fixture from '../common/media_library';
import { entry1 } from '../common/entries';
import * as specUtils from '../common/spec_utils';
const backend = 'git-gateway';
const provider = 'gitlab';
describe('Git Gateway (GitLab) Backend Media Library - Large Media', () => {
let taskResult = { data: {} };
before(() => {
specUtils.before(taskResult, { publish_mode: 'editorial_workflow', provider }, backend);
});
after(() => {
specUtils.after(taskResult, backend);
});
beforeEach(() => {
specUtils.beforeEach(taskResult, backend);
});
afterEach(() => {
specUtils.afterEach(taskResult, backend);
});
fixture({ entries: [entry1], getUser: () => taskResult.data.user });
});

View File

@ -0,0 +1,34 @@
import fixture from '../common/media_library';
import { entry1 } from '../common/entries';
import * as specUtils from '../common/spec_utils';
const backend = 'github';
describe('GitHub Backend Media Library - GraphQL API', () => {
const taskResult = { data: {} };
before(() => {
specUtils.before(
taskResult,
{
backend: { use_graphql: true },
publish_mode: 'editorial_workflow',
},
backend,
);
});
after(() => {
specUtils.after(taskResult, backend);
});
beforeEach(() => {
specUtils.beforeEach(taskResult, backend);
});
afterEach(() => {
specUtils.afterEach(taskResult, backend);
});
fixture({ entries: [entry1], getUser: () => taskResult.data.user });
});

View File

@ -0,0 +1,34 @@
import fixture from '../common/media_library';
import { entry1 } from '../common/entries';
import * as specUtils from '../common/spec_utils';
const backend = 'github';
describe('GitHub Backend Media Library - REST API', () => {
let taskResult = { data: {} };
before(() => {
specUtils.before(
taskResult,
{
backend: { use_graphql: false },
publish_mode: 'editorial_workflow',
},
backend,
);
});
after(() => {
specUtils.after(taskResult, backend);
});
beforeEach(() => {
specUtils.beforeEach(taskResult, backend);
});
afterEach(() => {
specUtils.afterEach(taskResult, backend);
});
fixture({ entries: [entry1], getUser: () => taskResult.data.user });
});

View File

@ -0,0 +1,27 @@
import fixture from '../common/media_library';
import { entry1 } from '../common/entries';
import * as specUtils from '../common/spec_utils';
const backend = 'gitlab';
describe('GitLab Backend Media Library - REST API', () => {
let taskResult = { data: {} };
before(() => {
specUtils.before(taskResult, { publish_mode: 'editorial_workflow' }, backend);
});
after(() => {
specUtils.after(taskResult, backend);
});
beforeEach(() => {
specUtils.beforeEach(taskResult, backend);
});
afterEach(() => {
specUtils.afterEach(taskResult, backend);
});
fixture({ entries: [entry1], getUser: () => taskResult.data.user });
});

View File

@ -0,0 +1,29 @@
import fixture from '../common/media_library';
import * as specUtils from '../common/spec_utils';
import { entry1 } from '../common/entries';
const backend = 'proxy';
const mode = 'git';
describe(`Proxy Backend Media Library - '${mode}' mode`, () => {
let taskResult = { data: {} };
before(() => {
specUtils.before(taskResult, { publish_mode: 'editorial_workflow', mode }, backend);
Cypress.config('defaultCommandTimeout', 5 * 1000);
});
after(() => {
specUtils.after(taskResult, backend);
});
beforeEach(() => {
specUtils.beforeEach(taskResult, backend);
});
afterEach(() => {
specUtils.afterEach(taskResult, backend);
});
fixture({ entries: [entry1], getUser: () => taskResult.data.user });
});

View File

@ -0,0 +1,21 @@
import fixture from '../common/media_library';
const entries = [
{
Title: 'first title',
Body: 'first body',
},
];
describe('Test Backend Media Library', () => {
after(() => {
cy.task('teardownBackend', { backend: 'test' });
});
before(() => {
Cypress.config('defaultCommandTimeout', 4000);
cy.task('setupBackend', { backend: 'test' });
});
fixture({ entries });
});

View File

@ -0,0 +1,71 @@
import { login } from '../../utils/steps';
const search = (term, collection) => {
cy.get('[class*=SearchInput]').clear({ force: true });
cy.get('[class*=SearchInput]').type(term, { force: true });
cy.get('[class*=SuggestionsContainer]').within(() => {
cy.contains(collection).click();
});
};
const assertSearchHeading = title => {
cy.get('[class*=SearchResultHeading]').should('have.text', title);
};
const assertSearchResult = (text, collection) => {
cy.get('[class*=ListCardLink] h2').contains(collection ?? text);
};
const assertNotInSearch = text => {
cy.get('[class*=ListCardLink] h2').contains(text).should('not.exist');
};
describe('Search Suggestion', () => {
before(() => {
Cypress.config('defaultCommandTimeout', 4000);
cy.task('setupBackend', { backend: 'test' });
});
after(() => {
cy.task('teardownBackend', { backend: 'test' });
});
beforeEach(() => {
login();
});
it('can search in all collections', () => {
search('this', 'All Collections');
assertSearchHeading('Search Results for "this"');
assertSearchResult('This is post # 20', 'Posts');
assertSearchResult('This is a TOML front matter post', 'Posts');
assertSearchResult('This is a JSON front matter post', 'Posts');
assertSearchResult('This is a YAML front matter post', 'Posts');
assertSearchResult('This FAQ item # 5', 'FAQ');
});
it('can search in posts collection', () => {
search('this', 'Posts');
assertSearchHeading('Search Results for "this" in Posts');
assertSearchResult('This is post # 20');
assertSearchResult('This is a TOML front matter post');
assertSearchResult('This is a JSON front matter post');
assertSearchResult('This is a YAML front matter post');
assertNotInSearch('This FAQ item # 5');
});
it('can search in faq collection', () => {
search('this', 'FAQ');
assertSearchHeading('Search Results for "this" in FAQ');
assertSearchResult('This FAQ item # 5');
assertNotInSearch('This is post # 20');
});
});

View File

@ -0,0 +1,30 @@
import fixture from '../common/simple_workflow';
import * as specUtils from '../common/spec_utils';
import type { TaskResult } from '../../interface';
const backend = 'bitbucket';
describe('BitBucket Backend Simple Workflow', () => {
let taskResult: TaskResult = { data: {} };
before(() => {
specUtils.before(taskResult, { publish_mode: 'simple' }, backend);
});
after(() => {
specUtils.after(backend);
});
beforeEach(() => {
specUtils.beforeEach(backend);
});
afterEach(() => {
specUtils.afterEach(backend);
});
fixture({
getUser: () => taskResult.data?.user,
});
});

View File

@ -0,0 +1,31 @@
import fixture from '../common/simple_workflow';
import * as specUtils from '../common/spec_utils';
import { entry1, entry2, entry3 } from '../common/entries';
const backend = 'git-gateway';
const provider = 'github';
describe('Git Gateway (GitHub) Backend Simple Workflow', () => {
let taskResult = { data: {} };
before(() => {
specUtils.before(taskResult, { publish_mode: 'simple', provider }, backend);
});
after(() => {
specUtils.after(taskResult, backend);
});
beforeEach(() => {
specUtils.beforeEach(taskResult, backend);
});
afterEach(() => {
specUtils.afterEach(taskResult, backend);
});
fixture({
entries: [entry1, entry2, entry3],
getUser: () => taskResult.data.user,
});
});

View File

@ -0,0 +1,31 @@
import fixture from '../common/simple_workflow';
import * as specUtils from '../common/spec_utils';
import { entry1, entry2, entry3 } from '../common/entries';
const backend = 'git-gateway';
const provider = 'gitlab';
describe('Git Gateway (GitLab) Backend Simple Workflow', () => {
let taskResult = { data: {} };
before(() => {
specUtils.before(taskResult, { publish_mode: 'simple', provider }, backend);
});
after(() => {
specUtils.after(taskResult, backend);
});
beforeEach(() => {
specUtils.beforeEach(taskResult, backend);
});
afterEach(() => {
specUtils.afterEach(taskResult, backend);
});
fixture({
entries: [entry1, entry2, entry3],
getUser: () => taskResult.data.user,
});
});

View File

@ -0,0 +1,37 @@
import fixture from '../common/simple_workflow';
import * as specUtils from '../common/spec_utils';
import { entry1, entry2, entry3 } from '../common/entries';
const backend = 'github';
describe('GitHub Backend Simple Workflow - GraphQL API', () => {
const taskResult = { data: {} };
before(() => {
specUtils.before(
taskResult,
{
backend: { use_graphql: true },
publish_mode: 'simple',
},
backend,
);
});
after(() => {
specUtils.after(taskResult, backend);
});
beforeEach(() => {
specUtils.beforeEach(taskResult, backend);
});
afterEach(() => {
specUtils.afterEach(taskResult, backend);
});
fixture({
entries: [entry1, entry2, entry3],
getUser: () => taskResult.data.user,
});
});

View File

@ -0,0 +1,37 @@
import fixture from '../common/simple_workflow';
import * as specUtils from '../common/spec_utils';
import { entry1, entry2, entry3 } from '../common/entries';
const backend = 'github';
describe('GitHub Backend Simple Workflow - REST API', () => {
let taskResult = { data: {} };
before(() => {
specUtils.before(
taskResult,
{
backend: { use_graphql: false },
publish_mode: 'simple',
},
backend,
);
});
after(() => {
specUtils.after(taskResult, backend);
});
beforeEach(() => {
specUtils.beforeEach(taskResult, backend);
});
afterEach(() => {
specUtils.afterEach(taskResult, backend);
});
fixture({
entries: [entry1, entry2, entry3],
getUser: () => taskResult.data.user,
});
});

View File

@ -0,0 +1,30 @@
import fixture from '../common/simple_workflow';
import * as specUtils from '../common/spec_utils';
import { entry1, entry2, entry3 } from '../common/entries';
const backend = 'gitlab';
describe('GitLab Backend Simple Workflow', () => {
let taskResult = { data: {} };
before(() => {
specUtils.before(taskResult, { publish_mode: 'simple' }, backend);
});
after(() => {
specUtils.after(taskResult, backend);
});
beforeEach(() => {
specUtils.beforeEach(taskResult, backend);
});
afterEach(() => {
specUtils.afterEach(taskResult, backend);
});
fixture({
entries: [entry1, entry2, entry3],
getUser: () => taskResult.data.user,
});
});

View File

@ -0,0 +1,32 @@
import fixture from '../common/simple_workflow';
import * as specUtils from '../common/spec_utils';
import { entry1, entry2, entry3 } from '../common/entries';
const backend = 'proxy';
const mode = 'fs';
describe(`Proxy Backend Simple Workflow - '${mode}' mode`, () => {
const taskResult = { data: {} };
before(() => {
specUtils.before(taskResult, { publish_mode: 'simple', mode }, backend);
Cypress.config('defaultCommandTimeout', 5 * 1000);
});
after(() => {
specUtils.after(taskResult, backend);
});
beforeEach(() => {
specUtils.beforeEach(taskResult, backend);
});
afterEach(() => {
specUtils.afterEach(taskResult, backend);
});
fixture({
entries: [entry1, entry2, entry3],
getUser: () => taskResult.data.user,
});
});

View File

@ -0,0 +1,35 @@
import fixture from '../common/simple_workflow';
import * as specUtils from '../common/spec_utils';
import { entry1, entry2, entry3 } from '../common/entries';
const backend = 'proxy';
const mode = 'git';
describe(`Proxy Backend Simple Workflow - '${mode}' mode`, () => {
let taskResult = { data: {} };
before(() => {
specUtils.before(taskResult, { publish_mode: 'simple', mode }, backend);
Cypress.config('defaultCommandTimeout', 5 * 1000);
});
after(() => {
specUtils.after(taskResult, backend);
});
beforeEach(() => {
if (Cypress.mocha.getRunner().suite.ctx.currentTest.title === 'can create an entry') {
Cypress.mocha.getRunner().suite.ctx.currentTest.skip();
}
specUtils.beforeEach(taskResult, backend);
});
afterEach(() => {
specUtils.afterEach(taskResult, backend);
});
fixture({
entries: [entry1, entry2, entry3],
getUser: () => taskResult.data.user,
});
});

View File

@ -0,0 +1,92 @@
import {
login,
createPost,
createPostAndExit,
updateExistingPostAndExit,
exitEditor,
goToWorkflow,
goToCollections,
updateWorkflowStatus,
publishWorkflowEntry,
assertWorkflowStatusInEditor,
assertPublishedEntry,
deleteEntryInEditor,
assertOnCollectionsPage,
assertEntryDeleted,
assertWorkflowStatus,
updateWorkflowStatusInEditor,
} from '../../utils/steps';
import { workflowStatus, editorStatus } from '../../utils/constants';
export default function ({ entries, getUser }) {
it('successfully loads', () => {
login({ user: getUser() });
});
it('can create an entry', () => {
login({ user: getUser() });
createPostAndExit(entries[0]);
});
it('can update an entry', () => {
login({ user: getUser() });
createPostAndExit(entries[0]);
updateExistingPostAndExit(entries[0], entries[1]);
});
it('can publish an editorial workflow entry', () => {
login({ user: getUser() });
createPostAndExit(entries[0]);
goToWorkflow();
updateWorkflowStatus(entries[0], workflowStatus.draft, workflowStatus.ready);
publishWorkflowEntry(entries[0]);
});
it('can change workflow status', () => {
login({ user: getUser() });
createPostAndExit(entries[0]);
goToWorkflow();
updateWorkflowStatus(entries[0], workflowStatus.draft, workflowStatus.review);
updateWorkflowStatus(entries[0], workflowStatus.review, workflowStatus.ready);
updateWorkflowStatus(entries[0], workflowStatus.ready, workflowStatus.review);
updateWorkflowStatus(entries[0], workflowStatus.review, workflowStatus.draft);
updateWorkflowStatus(entries[0], workflowStatus.draft, workflowStatus.ready);
});
it('can change status on and publish multiple entries', () => {
login({ user: getUser() });
createPostAndExit(entries[0]);
createPostAndExit(entries[1]);
createPostAndExit(entries[2]);
goToWorkflow();
updateWorkflowStatus(entries[2], workflowStatus.draft, workflowStatus.ready);
updateWorkflowStatus(entries[1], workflowStatus.draft, workflowStatus.ready);
updateWorkflowStatus(entries[0], workflowStatus.draft, workflowStatus.ready);
publishWorkflowEntry(entries[2]);
publishWorkflowEntry(entries[1]);
publishWorkflowEntry(entries[0]);
goToCollections();
assertPublishedEntry([entries[2], entries[1], entries[0]]);
});
it('can delete an entry', () => {
login({ user: getUser() });
createPost(entries[0]);
deleteEntryInEditor();
assertOnCollectionsPage();
assertEntryDeleted(entries[0]);
});
it('can update workflow status from within the editor', () => {
login({ user: getUser() });
createPost(entries[0]);
assertWorkflowStatusInEditor(editorStatus.draft);
updateWorkflowStatusInEditor(editorStatus.review);
assertWorkflowStatusInEditor(editorStatus.review);
updateWorkflowStatusInEditor(editorStatus.ready);
assertWorkflowStatusInEditor(editorStatus.ready);
exitEditor();
goToWorkflow();
assertWorkflowStatus(entries[0], workflowStatus.ready);
});
}

View File

@ -0,0 +1,50 @@
import {
login,
createPostAndExit,
goToWorkflow,
goToCollections,
updateWorkflowStatus,
publishWorkflowEntry,
assertPublishedEntry,
} from '../../utils/steps';
import { workflowStatus } from '../../utils/constants';
const versions = ['2.9.7', '2.10.24'];
export default function ({ entries, getUser }) {
versions.forEach(version => {
it(`migrate from ${version} to latest`, () => {
cy.task('switchToVersion', {
version,
});
cy.reload();
login({ user: getUser() });
createPostAndExit(entries[0]);
createPostAndExit(entries[1]);
createPostAndExit(entries[2]);
goToWorkflow();
updateWorkflowStatus(entries[2], workflowStatus.draft, workflowStatus.ready);
// eslint-disable-next-line cypress/no-unnecessary-waiting
cy.wait(1500); // older versions of the CMS didn't wait fully for the update to be resolved
updateWorkflowStatus(entries[1], workflowStatus.draft, workflowStatus.ready);
// eslint-disable-next-line cypress/no-unnecessary-waiting
cy.wait(1500); // older versions of the CMS didn't wait fully for the update to be resolved
updateWorkflowStatus(entries[0], workflowStatus.draft, workflowStatus.ready);
// eslint-disable-next-line cypress/no-unnecessary-waiting
cy.wait(1500); // older versions of the CMS didn't wait fully for the update to be resolved
cy.task('switchToVersion', {
version: 'latest',
});
cy.reload();
// allow migration code to run for 5 minutes
publishWorkflowEntry(entries[2], 5 * 60 * 1000);
publishWorkflowEntry(entries[1]);
publishWorkflowEntry(entries[0]);
goToCollections();
assertPublishedEntry([entries[2], entries[1], entries[0]]);
});
});
}

View File

@ -0,0 +1,121 @@
import type { Post } from '@staticcms/cypress/interface';
export const entry1: Post = {
Title: 'first title',
Body: 'first body',
Description: 'first description',
Category: 'first category',
Tags: 'tag1',
};
export const entry2: Post = {
Title: 'second title',
Body: 'second body',
Description: 'second description',
Category: 'second category',
Tags: 'tag2',
};
export const entry3: Post = {
Title: 'third title',
Body: 'third body',
Description: 'third description',
Category: 'third category',
Tags: 'tag3',
};
export const entry4: Post = {
Title: 'fourth title',
Body: 'fourth body',
Description: 'fourth description',
Category: 'fourth category',
Tags: 'tag4',
};
export const entry5: Post = {
Title: 'fifth title',
Body: 'fifth body',
Description: 'fifth description',
Category: 'fifth category',
Tags: 'tag5',
};
export const entry6: Post = {
Title: 'sixth title',
Body: 'sixth body',
Description: 'sixth description',
Category: 'sixth category',
Tags: 'tag6',
};
export const entry7: Post = {
Title: 'seventh title',
Body: 'seventh body',
Description: 'seventh description',
Category: 'seventh category',
Tags: 'tag7',
};
export const entry8: Post = {
Title: 'eighth title',
Body: 'eighth body',
Description: 'eighth description',
Category: 'eighth category',
Tags: 'tag8',
};
export const entry9: Post = {
Title: 'nineth title',
Body: 'nineth body',
Description: 'nineth description',
Category: 'nineth category',
Tags: 'tag9',
};
export const entry10: Post = {
Title: 'tenth title',
Body: 'tenth body',
Description: 'tenth description',
Category: 'tenth category',
Tags: 'tag10',
};
export const entry11: Post = {
Title: 'eleventh title',
Body: 'eleventh body',
Description: 'eleventh description',
Category: 'eleventh category',
Tags: 'tag11',
};
export const entry12: Post = {
Title: 'twelveth title',
Body: 'twelveth body',
Description: 'twelveth description',
Category: 'twelveth category',
Tags: 'tag12',
};
export const entry13: Post = {
Title: 'thirteenth title',
Body: 'thirteenth body',
Description: 'thirteenth description',
Category: 'thirteenth category',
Tags: 'tag13',
};
export const entry14: Post = {
Title: 'fourteenth title',
Body: 'fourteenth body',
Description: 'fourteenth description',
Category: 'fourteenth category',
Tags: 'tag14',
};
export const entry15: Post = {
Title: 'fifteenth title',
Body: 'fifteenth body',
Description: 'fifteenth description',
Category: 'fifteenth category',
Tags: 'tag15',
};

View File

@ -0,0 +1,55 @@
import { newPost, populateEntry, publishEntry, flushClockAndSave } from '../../utils/steps';
const enterTranslation = str => {
cy.get('[data-testid="field-Title"]').first().clear({ force: true });
cy.get('[data-testid="field-Title"]').first().type(str, { force: true });
};
const createAndTranslate = entry => {
newPost();
// fill the main entry
populateEntry(entry, () => undefined);
// fill the translation
cy.get('.Pane2').within(() => {
enterTranslation('de');
cy.contains('span', 'Writing in DE').click();
cy.contains('span', 'fr').click();
enterTranslation('fr');
});
};
export const updateTranslation = () => {
cy.get('.Pane2').within(() => {
enterTranslation('fr fr');
cy.contains('span', 'Writing in FR').click();
cy.contains('span', 'de').click();
enterTranslation('de de');
});
cy.contains('button', 'Save').click();
};
export const assertTranslation = () => {
cy.get('.Pane2').within(() => {
cy.get('[data-testid="field-Title"]').should('have.value', 'de');
cy.contains('span', 'Writing in DE').click();
cy.contains('span', 'fr').click();
cy.get('[data-testid="field-Title"]').should('have.value', 'fr');
});
};
export const createEntryTranslateAndPublish = entry => {
createAndTranslate(entry);
publishEntry();
};
export const createEntryTranslateAndSave = entry => {
createAndTranslate(entry);
flushClockAndSave();
};

View File

@ -0,0 +1,53 @@
import {
login,
goToWorkflow,
updateWorkflowStatus,
exitEditor,
publishWorkflowEntry,
goToEntry,
updateWorkflowStatusInEditor,
publishEntryInEditor,
assertPublishedEntryInEditor,
assertUnpublishedEntryInEditor,
assertUnpublishedChangesInEditor,
} from '../../utils/steps';
import { createEntryTranslateAndSave, assertTranslation, updateTranslation } from './i18n';
import { workflowStatus, editorStatus, publishTypes } from '../../utils/constants';
export default function ({ entry, getUser }) {
const structures = ['multiple_folders', 'multiple_files', 'single_file'];
structures.forEach(structure => {
it(`can create and publish entry with translation in ${structure} mode`, () => {
cy.task('updateConfig', { i18n: { structure } });
login({ user: getUser() });
createEntryTranslateAndSave(entry);
assertUnpublishedEntryInEditor();
exitEditor();
goToWorkflow();
updateWorkflowStatus(entry, workflowStatus.draft, workflowStatus.ready);
publishWorkflowEntry(entry);
goToEntry(entry);
assertTranslation();
assertPublishedEntryInEditor();
});
it(`can update translated entry in ${structure} mode`, () => {
cy.task('updateConfig', { i18n: { structure: 'multiple_folders' } });
login({ user: getUser() });
createEntryTranslateAndSave(entry);
assertUnpublishedEntryInEditor();
updateWorkflowStatusInEditor(editorStatus.ready);
publishEntryInEditor(publishTypes.publishNow);
exitEditor();
goToEntry(entry);
assertTranslation();
assertPublishedEntryInEditor();
updateTranslation();
assertUnpublishedChangesInEditor();
});
});
}

View File

@ -0,0 +1,162 @@
import {
login,
goToMediaLibrary,
newPost,
populateEntry,
exitEditor,
goToWorkflow,
updateWorkflowStatus,
publishWorkflowEntry,
goToEntry,
goToCollections,
} from '../../utils/steps';
import { workflowStatus } from '../../utils/constants';
function uploadMediaFile() {
assertNoImagesInLibrary();
const fixture = 'cypress/fixtures/media/netlify.png';
cy.get('input[type="file"]').selectFile(fixture, { force: true });
cy.contains('span', 'Uploading...').should('not.exist');
assertImagesInLibrary();
}
function assertImagesInLibrary() {
cy.get('img[class*="CardImage"]').should('exist');
}
function assertNoImagesInLibrary() {
cy.get('h1').contains('Loading...').should('not.exist');
cy.get('img[class*="CardImage"]').should('not.exist');
}
function deleteImage() {
cy.get('img[class*="CardImage"]').click();
cy.contains('button', 'Delete selected').click();
assertNoImagesInLibrary();
}
function chooseSelectedMediaFile() {
cy.contains('button', 'Choose selected').should('not.be.disabled');
cy.contains('button', 'Choose selected').click();
}
function chooseAnImage() {
cy.contains('button', 'Choose an image').click();
}
function waitForEntryToLoad() {
cy.contains('button', 'Saving...').should('not.exist');
cy.contains('div', 'Loading entry...').should('not.exist');
}
function matchImageSnapshot() {
// cy.matchImageSnapshot();
}
function newPostAndUploadImage() {
newPost();
chooseAnImage();
uploadMediaFile();
}
function newPostWithImage(entry) {
newPostAndUploadImage();
chooseSelectedMediaFile();
populateEntry(entry);
waitForEntryToLoad();
}
function publishPostWithImage(entry) {
newPostWithImage(entry);
// eslint-disable-next-line cypress/no-unnecessary-waiting
cy.wait(500);
exitEditor();
goToWorkflow();
updateWorkflowStatus(entry, workflowStatus.draft, workflowStatus.ready);
publishWorkflowEntry(entry);
}
function closeMediaLibrary() {
cy.get('button[class*="CloseButton"]').click();
}
function switchToGridView() {
cy.get('div[class*="ViewControls"]').within(() => {
cy.get('button').last().click();
});
}
function assertGridEntryImage(entry) {
cy.contains('li', entry.title).within(() => {
cy.get('div[class*="CardImage"]').should('be.visible');
});
}
export default function ({ entries, getUser }) {
beforeEach(() => {
login(getUser && getUser());
});
it('can upload image from global media library', () => {
goToMediaLibrary();
uploadMediaFile();
matchImageSnapshot();
closeMediaLibrary();
});
it('can delete image from global media library', () => {
goToMediaLibrary();
uploadMediaFile();
closeMediaLibrary();
goToMediaLibrary();
deleteImage();
matchImageSnapshot();
closeMediaLibrary();
});
it('can upload image from entry media library', () => {
newPostAndUploadImage();
matchImageSnapshot();
closeMediaLibrary();
exitEditor();
});
it('can save entry with image', () => {
newPostWithImage(entries[0]);
matchImageSnapshot();
exitEditor();
});
it('can publish entry with image', () => {
publishPostWithImage(entries[0]);
goToEntry(entries[0]);
waitForEntryToLoad();
matchImageSnapshot();
});
it('should not show draft entry image in global media library', () => {
newPostWithImage(entries[0]);
exitEditor();
goToMediaLibrary();
assertNoImagesInLibrary();
matchImageSnapshot();
});
it('should show published entry image in global media library', () => {
publishPostWithImage(entries[0]);
goToMediaLibrary();
assertImagesInLibrary();
matchImageSnapshot();
});
it('should show published entry image in grid view', () => {
publishPostWithImage(entries[0]);
goToCollections();
switchToGridView();
assertGridEntryImage(entries[0]);
matchImageSnapshot();
});
}

View File

@ -0,0 +1,75 @@
import {
login,
createPostAndExit,
updateExistingPostAndExit,
goToWorkflow,
deleteWorkflowEntry,
updateWorkflowStatus,
publishWorkflowEntry,
} from '../../utils/steps';
import { workflowStatus } from '../../utils/constants';
export default function ({ entries, getUser, getForkUser }) {
it('successfully loads', () => {
login({ user: getUser() });
});
it('can create an entry', () => {
login({ user: getUser() });
createPostAndExit(entries[0]);
});
it('can update an entry', () => {
login({ user: getUser() });
createPostAndExit(entries[0]);
updateExistingPostAndExit(entries[0], entries[1]);
});
it('can publish an editorial workflow entry', () => {
login({ user: getUser() });
createPostAndExit(entries[0]);
goToWorkflow();
updateWorkflowStatus(entries[0], workflowStatus.draft, workflowStatus.ready);
publishWorkflowEntry(entries[0]);
});
it('successfully forks repository and loads', () => {
login({ user: getForkUser() });
});
it('can create an entry on fork', () => {
login({ user: getForkUser() });
createPostAndExit(entries[0]);
});
it('can update a draft entry on fork', () => {
login({ user: getForkUser() });
createPostAndExit(entries[0]);
updateExistingPostAndExit(entries[0], entries[1]);
});
it('can change entry status from fork', () => {
login({ user: getForkUser() });
createPostAndExit(entries[0]);
goToWorkflow();
updateWorkflowStatus(entries[0], workflowStatus.draft, workflowStatus.review);
});
it('can delete review entry from fork', () => {
login({ user: getForkUser() });
createPostAndExit(entries[0]);
goToWorkflow();
updateWorkflowStatus(entries[0], workflowStatus.draft, workflowStatus.review);
deleteWorkflowEntry(entries[0]);
});
it('can return entry to draft and delete it', () => {
login({ user: getForkUser() });
createPostAndExit(entries[0]);
goToWorkflow();
updateWorkflowStatus(entries[0], workflowStatus.draft, workflowStatus.review);
updateWorkflowStatus(entries[0], workflowStatus.review, workflowStatus.draft);
deleteWorkflowEntry(entries[0]);
});
}

View File

@ -0,0 +1,85 @@
import {
assertPublishedEntry,
createPostAndPublish,
createPostPublishAndCreateNew,
createPostPublishAndDuplicate,
duplicatePostAndPublish,
editPostAndPublish,
editPostPublishAndCreateNew,
editPostPublishAndDuplicate,
login,
} from '../../utils/steps';
import {
entry1,
entry10,
entry2,
entry3,
entry4,
entry5,
entry6,
entry7,
entry8,
entry9,
} from './entries';
import type { User } from '@staticcms/cypress/interface';
export interface SimpleWorkflowProps {
getUser?: () => User | undefined;
}
export default function ({ getUser }: SimpleWorkflowProps = {}) {
it('successfully loads', () => {
login({ user: getUser?.() });
});
it('can create an entry', () => {
login({ user: getUser?.() });
createPostAndPublish(entry1);
assertPublishedEntry(entry1);
});
it('can publish a new entry and create new', () => {
login();
createPostPublishAndCreateNew(entry2);
assertPublishedEntry(entry2);
});
it('can publish a new entry and duplicate', () => {
login();
createPostPublishAndDuplicate(entry3);
assertPublishedEntry(entry3);
});
it('can edit an existing entry and publish', () => {
login();
createPostAndPublish(entry4);
assertPublishedEntry(entry4);
editPostAndPublish(entry4, entry5);
});
it('can edit an existing entry, publish and create new', () => {
login();
createPostAndPublish(entry6);
assertPublishedEntry(entry6);
editPostPublishAndCreateNew(entry6, entry7);
});
it('can edit an existing entry, publish and duplicate', () => {
login();
createPostAndPublish(entry8);
assertPublishedEntry(entry8);
editPostPublishAndDuplicate(entry8, entry9);
});
it('can duplicate an existing entry', () => {
login();
createPostAndPublish(entry10);
assertPublishedEntry(entry10);
duplicatePostAndPublish(entry10);
});
}

View File

@ -0,0 +1,47 @@
import type { Config } from '@staticcms/core/interface';
import type { TaskResult } from 'cypress/interface';
export const before = (taskResult: TaskResult, options: Partial<Config>, backend: string) => {
Cypress.config('taskTimeout', 7 * 60 * 1000);
cy.task('setupBackend', { backend, options }).then(data => {
taskResult.data = data;
});
};
export const after = (backend: string) => {
cy.task('teardownBackend', {
backend,
});
};
export const beforeEach = (backend: string) => {
cy.task('setupBackendTest', {
backend,
testName: Cypress.currentTest.title,
});
};
export const afterEach = (backend: string) => {
cy.task('teardownBackendTest', {
backend,
testName: Cypress.currentTest.title,
});
const {
suite: {
ctx: {
currentTest: { state, _retries: retries, _currentRetry: currentRetry },
},
},
} = (Cypress as any).mocha.getRunner();
if (state === 'failed' && retries === currentRetry) {
(Cypress as any).runner.stop();
}
};
export const seedRepo = (backend: string) => {
cy.task('seedRepo', {
backend,
});
};

View File

@ -0,0 +1,358 @@
import format from 'date-fns/format';
import {
login,
createPost,
createPostAndExit,
exitEditor,
goToWorkflow,
updateWorkflowStatus,
publishWorkflowEntry,
assertWorkflowStatusInEditor,
assertPublishedEntry,
deleteEntryInEditor,
assertOnCollectionsPage,
assertEntryDeleted,
assertWorkflowStatus,
updateWorkflowStatusInEditor,
unpublishEntry,
publishEntryInEditor,
duplicateEntry,
goToEntry,
populateEntry,
publishAndCreateNewEntryInEditor,
publishAndDuplicateEntryInEditor,
assertNotification,
assertFieldValidationError,
} from '../utils/steps';
import { editorStatus, publishTypes, notifications } from '../utils/constants';
import {
entry1,
entry10,
entry11,
entry12,
entry13,
entry14,
entry15,
entry2,
entry3,
entry4,
entry5,
entry6,
entry7,
entry8,
entry9,
} from './common/entries';
import { WorkflowStatus } from '@staticcms/core/constants/publishModes';
describe('Test Backend Editorial Workflow', () => {
after(() => {
cy.task('teardownBackend', { backend: 'test' });
});
before(() => {
Cypress.config('defaultCommandTimeout', 4000);
cy.task('setupBackend', { backend: 'test' });
});
beforeEach(() => {
cy.task('updateConfig', {
collections: [{ publish: true }],
publish_mode: 'editorial_workflow',
});
});
it('successfully loads', () => {
login({ editorialWorkflow: true });
});
it('can create an entry', () => {
login({ editorialWorkflow: true });
cy.get('[data-testid="sidebar-collection-nav-Posts').click();
createPost(entry1);
// new entry should show 'Delete unpublished entry'
cy.wait(1000);
cy.get('[data-testid="editor-extra-menu"]', { timeout: 5000 }).should('be.enabled');
cy.get('[data-testid="editor-extra-menu"]').click();
cy.contains('[data-testid="delete-button"]', 'Delete unpublished entry');
cy.url().should(
'eq',
`http://localhost:8080/#/collections/posts/entries/${format(
new Date(),
'yyyy-MM-dd',
)}-${entry1.Title.toLowerCase().replace(/\s/, '-')}`,
);
exitEditor();
});
it.only('can publish an editorial workflow entry', () => {
login({ editorialWorkflow: true });
cy.get('[data-testid="sidebar-collection-nav-Posts').click();
createPostAndExit(entry2);
goToWorkflow();
updateWorkflowStatus(entry2, WorkflowStatus.DRAFT, WorkflowStatus.PENDING_PUBLISH);
publishWorkflowEntry(entry2);
});
it('can update an entry', () => {
login({ editorialWorkflow: true });
cy.get('[data-testid="sidebar-collection-nav-Posts').click();
createPostAndExit(entry3);
goToWorkflow();
updateWorkflowStatus(entry3, WorkflowStatus.DRAFT, WorkflowStatus.PENDING_PUBLISH);
publishWorkflowEntry(entry3);
goToEntry(entry3);
populateEntry(entry4);
// existing entry should show 'Delete unpublished changes'
cy.contains('button', 'Delete unpublished changes');
// existing entry slug should remain the same after save'
cy.url().should(
'eq',
`http://localhost:8080/#/collections/posts/entries/${format(
new Date(),
'yyyy-MM-dd',
)}-${entry3.Title.toLowerCase().replace(/\s/, '-')}`,
);
exitEditor();
});
it('can change workflow status', () => {
login({ editorialWorkflow: true });
cy.get('[data-testid="sidebar-collection-nav-Posts').click();
createPostAndExit(entry5);
goToWorkflow();
updateWorkflowStatus(entry5, WorkflowStatus.DRAFT, WorkflowStatus.PENDING_REVIEW);
updateWorkflowStatus(entry5, WorkflowStatus.PENDING_REVIEW, WorkflowStatus.PENDING_PUBLISH);
updateWorkflowStatus(entry5, WorkflowStatus.PENDING_PUBLISH, WorkflowStatus.PENDING_REVIEW);
updateWorkflowStatus(entry5, WorkflowStatus.PENDING_REVIEW, WorkflowStatus.DRAFT);
updateWorkflowStatus(entry5, WorkflowStatus.DRAFT, WorkflowStatus.PENDING_PUBLISH);
});
it('can change status on and publish multiple entries', () => {
login({ editorialWorkflow: true });
cy.get('[data-testid="sidebar-collection-nav-Posts').click();
createPostAndExit(entry6);
createPostAndExit(entry7);
createPostAndExit(entry8);
goToWorkflow();
updateWorkflowStatus(entry8, WorkflowStatus.DRAFT, WorkflowStatus.PENDING_PUBLISH);
updateWorkflowStatus(entry7, WorkflowStatus.DRAFT, WorkflowStatus.PENDING_PUBLISH);
updateWorkflowStatus(entry6, WorkflowStatus.DRAFT, WorkflowStatus.PENDING_PUBLISH);
publishWorkflowEntry(entry8);
publishWorkflowEntry(entry7);
publishWorkflowEntry(entry6);
assertPublishedEntry([entry8, entry7, entry6]);
});
it('can delete an entry', () => {
login({ editorialWorkflow: true });
cy.get('[data-testid="sidebar-collection-nav-Posts').click();
createPost(entry9);
deleteEntryInEditor();
assertOnCollectionsPage();
assertEntryDeleted(entry9);
});
it('can update workflow status from within the editor', () => {
login({ editorialWorkflow: true });
cy.get('[data-testid="sidebar-collection-nav-Posts').click();
createPost(entry10);
assertWorkflowStatusInEditor(editorStatus.draft);
updateWorkflowStatusInEditor(editorStatus.review);
assertWorkflowStatusInEditor(editorStatus.review);
updateWorkflowStatusInEditor(editorStatus.ready);
assertWorkflowStatusInEditor(editorStatus.ready);
exitEditor();
goToWorkflow();
assertWorkflowStatus(entry10, WorkflowStatus.PENDING_PUBLISH);
});
it('can unpublish an existing entry', () => {
// first publish an entry
login({ editorialWorkflow: true });
cy.get('[data-testid="sidebar-collection-nav-Posts').click();
createPostAndExit(entry11);
goToWorkflow();
updateWorkflowStatus(entry11, WorkflowStatus.DRAFT, WorkflowStatus.PENDING_PUBLISH);
publishWorkflowEntry(entry11);
// then unpublish it
unpublishEntry(entry11);
});
it('can duplicate an existing entry', () => {
login({ editorialWorkflow: true });
cy.get('[data-testid="sidebar-collection-nav-Posts').click();
createPost(entry12);
updateWorkflowStatusInEditor(editorStatus.ready);
publishEntryInEditor(publishTypes.publishNow);
duplicateEntry(entry12);
});
it('cannot publish when "publish" is false', () => {
cy.task('updateConfig', { collections: [{ publish: false }] });
login({ editorialWorkflow: true });
cy.get('[data-testid="sidebar-collection-nav-Posts').click();
createPost(entry13);
cy.contains('span', 'Publish').should('not.exist');
exitEditor();
goToWorkflow();
updateWorkflowStatus(entry13, WorkflowStatus.DRAFT, WorkflowStatus.PENDING_PUBLISH);
cy.contains('button', 'Publish new entry').should('not.exist');
});
it('can create a new entry, publish and create new', () => {
login({ editorialWorkflow: true });
cy.get('[data-testid="sidebar-collection-nav-Posts').click();
createPost(entry14);
updateWorkflowStatusInEditor(editorStatus.ready);
publishAndCreateNewEntryInEditor();
});
it('can create a new entry, publish and duplicate', () => {
login({ editorialWorkflow: true });
cy.get('[data-testid="sidebar-collection-nav-Posts').click();
createPost(entry15);
updateWorkflowStatusInEditor(editorStatus.ready);
publishAndDuplicateEntryInEditor(entry15);
});
const inSidebar = (func: (currentSubject: JQuery<HTMLElement>) => void) => {
cy.get('[class*=SidebarNavList]').within(func);
};
const inGrid = (func: (currentSubject: JQuery<HTMLElement>) => void) => {
cy.get('[class*=CardsGrid]').within(func);
};
it('can access nested collection items', () => {
login();
inSidebar(() => cy.contains('a', 'Pages').click());
inSidebar(() => cy.contains('a', 'Directory'));
inGrid(() => cy.contains('a', 'Root Page'));
inGrid(() => cy.contains('a', 'Directory'));
inSidebar(() => cy.contains('a', 'Directory').click());
inGrid(() => cy.contains('a', 'Sub Directory'));
inGrid(() => cy.contains('a', 'Another Sub Directory'));
inSidebar(() => cy.contains('a', 'Sub Directory').click());
inGrid(() => cy.contains('a', 'Nested Directory'));
cy.url().should(
'eq',
'http://localhost:8080/#/collections/pages/filter/directory/sub-directory',
);
inSidebar(() => cy.contains('a', 'Pages').click());
inSidebar(() => cy.contains('a', 'Pages').click());
inGrid(() => cy.contains('a', 'Another Sub Directory').should('not.exist'));
});
it('can navigate to nested entry', () => {
login();
inSidebar(() => cy.contains('a', 'Pages').click());
inSidebar(() => cy.contains('a', 'Directory').click());
inGrid(() => cy.contains('a', 'Another Sub Directory').click());
cy.url().should(
'eq',
'http://localhost:8080/#/collections/pages/entries/directory/another-sub-directory/index',
);
});
it(`can create a new entry with custom path`, () => {
login();
inSidebar(() => cy.contains('a', 'Pages').click());
inSidebar(() => cy.contains('a', 'Directory').click());
inSidebar(() => cy.contains('a', 'Sub Directory').click());
cy.contains('a', 'New Page').click();
cy.get('[data-testid="field-Path"]').should('have.value', 'directory/sub-directory');
cy.get('[data-testid="field-Path"]').type('/new-path');
cy.get('[data-testid="field-Title"]').type('New Path Title');
cy.wait(150);
cy.contains('button', 'Save').click();
assertNotification(notifications.saved);
updateWorkflowStatusInEditor(editorStatus.ready);
publishEntryInEditor(publishTypes.publishNow);
exitEditor();
inGrid(() => cy.contains('a', 'New Path Title'));
inSidebar(() => cy.contains('a', 'Directory').click());
inSidebar(() => cy.contains('a', 'Directory').click());
inGrid(() => cy.contains('a', 'New Path Title').should('not.exist'));
});
it(`can't create an entry with an existing path`, () => {
login();
inSidebar(() => cy.contains('a', 'Pages').click());
inSidebar(() => cy.contains('a', 'Directory').click());
inSidebar(() => cy.contains('a', 'Sub Directory').click());
cy.contains('a', 'New Page').click();
cy.get('[data-testid="field-Title"]').type('New Path Title');
cy.wait(150);
cy.contains('button', 'Save').click();
assertFieldValidationError({
message: `Path 'directory/sub-directory' already exists`,
fieldLabel: 'Path',
});
});
it('can move an existing entry to a new path', () => {
login();
inSidebar(() => cy.contains('a', 'Pages').click());
inGrid(() => cy.contains('a', 'Directory').click());
cy.get('[data-testid="field-Path"]').should('have.value', 'directory');
cy.get('[data-testid="field-Path"]').clear();
cy.get('[data-testid="field-Path"]').type('new-directory');
cy.get('[data-testid="field-Title"]').clear();
cy.get('[data-testid="field-Title"]').type('New Directory');
cy.wait(150);
cy.contains('button', 'Save').click();
assertNotification(notifications.saved);
updateWorkflowStatusInEditor(editorStatus.ready);
publishEntryInEditor(publishTypes.publishNow);
exitEditor();
inSidebar(() => cy.contains('a', 'New Directory').click());
inGrid(() => cy.contains('a', 'Sub Directory'));
inGrid(() => cy.contains('a', 'Another Sub Directory'));
});
});

View File

@ -0,0 +1,28 @@
import fixture from './common/simple_workflow';
import * as specUtils from './common/spec_utils';
import type { TaskResult } from '../interface';
const backend = 'test';
describe('Test Backend Simple Workflow', () => {
const taskResult: TaskResult = { data: {} };
before(() => {
specUtils.before(taskResult, { publish_mode: 'simple' }, backend);
});
after(() => {
specUtils.after(backend);
});
beforeEach(() => {
specUtils.beforeEach(backend);
});
afterEach(() => {
specUtils.afterEach(backend);
});
fixture();
});

View File

@ -0,0 +1,60 @@
import { login } from '../utils/steps';
const filter = (term: string) => {
cy.get('[data-testid="filter-by"]').click();
cy.get(`[data-testid="filter-by-option-${term}"]`).click();
};
const assertEntriesCount = (count: number) => {
cy.get('[class*=CMS_Entries_entry-listing-table-row]').should('have.length', count);
};
describe('View Filter', () => {
before(() => {
Cypress.config('defaultCommandTimeout', 4000);
cy.task('setupBackend', { backend: 'test' });
});
after(() => {
cy.task('teardownBackend', { backend: 'test' });
});
beforeEach(() => {
login();
});
it('can apply string filter', () => {
// enable filter
filter('Posts With Index');
assertEntriesCount(20);
// disable filter
filter('Posts With Index');
});
it('can apply boolean filter', () => {
// enable filter
filter('Drafts');
assertEntriesCount(10);
// disable filter
filter('Drafts');
assertEntriesCount(23);
});
it('can apply multiple filters', () => {
// enable filter
filter('Posts Without Index');
assertEntriesCount(3);
filter('Drafts');
assertEntriesCount(0);
cy.contains('div', 'No Entries');
});
});

View File

@ -0,0 +1,58 @@
import { login } from '../utils/steps';
const group = (term: string) => {
cy.get('[data-testid="group-by"]').click();
cy.get(`[data-testid="group-by-option-${term}"]`).click();
};
const assertGroupsCount = (count: number) => {
cy.get('[class*=CMS_Entries_group-button]').should('have.length', count);
};
const assertEachGroupCount = (name: string, count: number) => {
cy.get(`[data-testid="group-by-${name}"]`).click();
assertEntriesCount(count);
};
const assertEntriesCount = (count: number) => {
cy.get('[class*=CMS_Entries_entry-listing-table-row]').should('have.length', count);
};
describe('View Group', () => {
before(() => {
Cypress.config('defaultCommandTimeout', 4000);
cy.task('setupBackend', { backend: 'test' });
});
after(() => {
cy.task('teardownBackend', { backend: 'test' });
});
beforeEach(() => {
login();
});
it('can apply string group', () => {
// enable group
group('Year');
assertGroupsCount(2);
const year = new Date().getFullYear();
assertEachGroupCount(`Year ${year}`, 20);
assertEachGroupCount('Year 2015', 3);
//disable group
group('Year');
assertEntriesCount(23);
//enable group
group('Drafts');
assertGroupsCount(3);
assertEachGroupCount('Drafts', 10);
assertEachGroupCount('Not Drafts', 10);
assertEachGroupCount('Other', 3);
});
});

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

55
cypress/interface.ts Normal file
View File

@ -0,0 +1,55 @@
import type { Config } from '@staticcms/core/interface';
export interface TaskResult {
data: SetupBackendResponse;
}
export interface SetupBackendProps {
backend: string;
options: Partial<Config>;
}
export interface User {
email: string;
password: string;
netlifySiteURL?: string;
}
export type SetupBackendResponse = null | {
user?: User;
};
export interface SetupBackendTestProps {
backend: string;
testName: string;
}
export interface TeardownBackendTestProps {
backend: string;
testName: string;
}
export interface SeedRepoProps {
backend: string;
}
export interface TeardownBackendProps {
backend: string;
}
export interface Post {
Title: string;
Body: string;
Description: string;
Category: string;
Tags: string;
}
export interface Author {
name: string;
description: string;
}
export interface Authors {
authors: Author[];
}

View File

@ -0,0 +1,310 @@
const fs = require('fs-extra');
const fetch = require('node-fetch');
const path = require('path');
const { updateConfig } = require('../utils/config');
const { escapeRegExp } = require('../utils/regexp');
const {
getExpectationsFilename,
transformRecordedData: transformData,
getGitClient,
} = require('./common');
const { merge } = require('lodash');
const BITBUCKET_REPO_OWNER_SANITIZED_VALUE = 'owner';
const BITBUCKET_REPO_NAME_SANITIZED_VALUE = 'repo';
const BITBUCKET_REPO_TOKEN_SANITIZED_VALUE = 'fakeToken';
const FAKE_OWNER_USER = {
name: 'owner',
display_name: 'owner',
links: {
avatar: {
href: 'https://avatars1.githubusercontent.com/u/7892489?v=4',
},
},
nickname: 'owner',
};
async function getEnvs() {
const {
BITBUCKET_REPO_OWNER: owner,
BITBUCKET_REPO_NAME: repo,
BITBUCKET_OUATH_CONSUMER_KEY: consumerKey,
BITBUCKET_OUATH_CONSUMER_SECRET: consumerSecret,
} = process.env;
if (!owner || !repo || !consumerKey || !consumerSecret) {
throw new Error(
'Please set BITBUCKET_REPO_OWNER, BITBUCKET_REPO_NAME, BITBUCKET_OUATH_CONSUMER_KEY, BITBUCKET_OUATH_CONSUMER_SECRET environment variables',
);
}
const params = new URLSearchParams();
params.append('grant_type', 'client_credentials');
const { access_token: token } = await fetch(
`https://${consumerKey}:${consumerSecret}@bitbucket.org/site/oauth2/access_token`,
{ method: 'POST', body: params },
).then(r => r.json());
return { owner, repo, token };
}
const API_URL = 'https://api.bitbucket.org/2.0/';
function get(token, path) {
return fetch(`${API_URL}${path}`, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
}).then(r => r.json());
}
function post(token, path, body) {
return fetch(`${API_URL}${path}`, {
method: 'POST',
...(body ? { body } : {}),
headers: {
'Content-Type': 'application/json',
...(body ? { 'Content-Type': 'application/json' } : {}),
Authorization: `Bearer ${token}`,
},
});
}
function del(token, path) {
return fetch(`${API_URL}${path}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
});
}
async function prepareTestBitBucketRepo({ lfs }) {
const { owner, repo, token } = await getEnvs();
// postfix a random string to avoid collisions
const postfix = Math.random().toString(32).slice(2);
const testRepoName = `${repo}-${Date.now()}-${postfix}`;
console.info('Creating repository', testRepoName, token);
const response = await post(
token,
`repositories/${owner}/${testRepoName}`,
JSON.stringify({ scm: 'git' }),
);
if (!response.ok) {
throw new Error(`Unable to create repository. ${response.statusText}`);
}
const tempDir = path.join('.temp', testRepoName);
await fs.remove(tempDir);
let git = getGitClient();
const repoUrl = `git@bitbucket.org:${owner}/${repo}.git`;
console.info('Cloning repository', repoUrl);
await git.clone(repoUrl, tempDir);
git = getGitClient(tempDir);
console.info('Pushing to new repository', testRepoName);
console.info('Updating remote...');
await git.removeRemote('origin');
await git.addRemote('origin', `git@bitbucket.org:${owner}/${testRepoName}`);
console.info('Pushing...');
await git.push(['-u', 'origin', 'main']);
console.info('Pushed to new repository', testRepoName);
if (lfs) {
console.info(`Enabling LFS for repo ${owner}/${repo}`);
await git.addConfig('commit.gpgsign', 'false');
await git.raw(['lfs', 'track', '*.png', '*.jpg']);
await git.add('.gitattributes');
await git.commit('chore: track images files under LFS');
await git.push('origin', 'main');
}
return { owner, repo: testRepoName, tempDir };
}
async function getUser() {
const { token } = await getEnvs();
const user = await get(token, 'user');
return { ...user, token, backendName: 'bitbucket' };
}
async function deleteRepositories({ owner, repo, tempDir }) {
const { token } = await getEnvs();
console.info('Deleting repository', `${owner}/${repo}`);
await fs.remove(tempDir);
await del(token, `repositories/${owner}/${repo}`);
}
async function resetOriginRepo({ owner, repo, tempDir }) {
console.info('Resetting origin repo:', `${owner}/${repo}`);
const { token } = await getEnvs();
const pullRequests = await get(token, `repositories/${owner}/${repo}/pullrequests`);
const ids = pullRequests.values.map(mr => mr.id);
console.info('Closing pull requests:', ids);
await Promise.all(
ids.map(id => post(token, `repositories/${owner}/${repo}/pullrequests/${id}/decline`)),
);
const branches = await get(token, `repositories/${owner}/${repo}/refs/branches`);
const toDelete = branches.values.filter(b => b.name !== 'main').map(b => b.name);
console.info('Deleting branches', toDelete);
await Promise.all(
toDelete.map(branch => del(token, `repositories/${owner}/${repo}/refs/branches/${branch}`)),
);
console.info('Resetting main');
const git = getGitClient(tempDir);
await git.push(['--force', 'origin', 'main']);
console.info('Done resetting origin repo:', `${owner}/${repo}`);
}
async function resetRepositories({ owner, repo, tempDir }) {
await resetOriginRepo({ owner, repo, tempDir });
}
async function setupBitBucket(options) {
const { lfs = false, ...rest } = options;
console.info('Running tests - live data will be used!');
const [user, repoData] = await Promise.all([getUser(), prepareTestBitBucketRepo({ lfs })]);
console.info('Updating config...');
await updateConfig(config => {
merge(config, rest, {
backend: {
repo: `${repoData.owner}/${repoData.repo}`,
},
});
});
return { ...repoData, user };
}
async function teardownBitBucket(taskData) {
await deleteRepositories(taskData);
return null;
}
async function setupBitBucketTest(taskData) {
await resetRepositories(taskData);
return null;
}
const sanitizeString = (str, { owner, repo, token, ownerName }) => {
let replaced = str
.replace(new RegExp(escapeRegExp(owner), 'g'), BITBUCKET_REPO_OWNER_SANITIZED_VALUE)
.replace(new RegExp(escapeRegExp(repo), 'g'), BITBUCKET_REPO_NAME_SANITIZED_VALUE)
.replace(new RegExp(escapeRegExp(token), 'g'), BITBUCKET_REPO_TOKEN_SANITIZED_VALUE)
.replace(
new RegExp('https://secure.gravatar.+?/u/.+?v=\\d', 'g'),
`${FAKE_OWNER_USER.links.avatar.href}`,
)
.replace(new RegExp(/\?token=.+?&/g), 'token=fakeToken&')
.replace(new RegExp(/&client=.+?&/g), 'client=fakeClient&');
if (ownerName) {
replaced = replaced.replace(
new RegExp(escapeRegExp(ownerName), 'g'),
FAKE_OWNER_USER.display_name,
);
}
return replaced;
};
const transformRecordedData = (expectation, toSanitize) => {
const requestBodySanitizer = httpRequest => {
let body;
if (httpRequest.body && httpRequest.body.type === 'JSON' && httpRequest.body.json) {
const bodyObject = JSON.parse(httpRequest.body.json);
if (bodyObject.encoding === 'base64') {
// sanitize encoded data
const decodedBody = Buffer.from(bodyObject.content, 'base64').toString('binary');
const sanitizedContent = sanitizeString(decodedBody, toSanitize);
const sanitizedEncodedContent = Buffer.from(sanitizedContent, 'binary').toString('base64');
bodyObject.content = sanitizedEncodedContent;
body = JSON.stringify(bodyObject);
} else {
body = httpRequest.body.json;
}
} else if (httpRequest.body && httpRequest.body.type === 'STRING' && httpRequest.body.string) {
body = httpRequest.body.string;
} else if (
httpRequest.body &&
httpRequest.body.type === 'BINARY' &&
httpRequest.body.base64Bytes
) {
body = {
encoding: 'base64',
content: httpRequest.body.base64Bytes,
contentType: httpRequest.body.contentType,
};
}
return body;
};
const responseBodySanitizer = (httpRequest, httpResponse) => {
let responseBody = null;
if (httpResponse.body && httpResponse.body.string) {
responseBody = httpResponse.body.string;
} else if (
httpResponse.body &&
httpResponse.body.type === 'BINARY' &&
httpResponse.body.base64Bytes
) {
responseBody = {
encoding: 'base64',
content: httpResponse.body.base64Bytes,
};
} else if (httpResponse.body) {
if (httpResponse.body && httpResponse.body.type === 'JSON' && httpResponse.body.json) {
responseBody = JSON.stringify(httpResponse.body.json);
} else {
responseBody = httpResponse.body;
}
}
// replace recorded user with fake one
if (
responseBody &&
httpRequest.path === '/2.0/user' &&
httpRequest.headers.host.includes('api.bitbucket.org')
) {
responseBody = JSON.stringify(FAKE_OWNER_USER);
}
return responseBody;
};
return transformData(expectation, requestBodySanitizer, responseBodySanitizer);
};
async function teardownBitBucketTest(taskData) {
await resetRepositories(taskData);
return null;
}
module.exports = {
setupBitBucket,
teardownBitBucket,
setupBitBucketTest,
teardownBitBucketTest,
};

80
cypress/plugins/common.js Normal file
View File

@ -0,0 +1,80 @@
const path = require('path');
const { default: simpleGit } = require('simple-git');
const GIT_SSH_COMMAND = 'ssh -o UserKnownHostsFile=/dev/null -o StrictHostKeyChecking=no';
const GIT_SSL_NO_VERIFY = true;
const getExpectationsFilename = taskData => {
const { spec, testName } = taskData;
const basename = `${spec}__${testName}`;
const fixtures = path.join(__dirname, '..', 'fixtures');
const filename = path.join(fixtures, `${basename}.json`);
return filename;
};
const HEADERS_TO_IGNORE = [
'Date',
'X-RateLimit-Remaining',
'X-RateLimit-Reset',
'ETag',
'Last-Modified',
'X-GitHub-Request-Id',
'X-NF-Request-ID',
'X-Request-Id',
'X-Runtime',
'RateLimit-Limit',
'RateLimit-Observed',
'RateLimit-Remaining',
'RateLimit-Reset',
'RateLimit-ResetTime',
'GitLab-LB',
].map(h => h.toLowerCase());
const transformRecordedData = (expectation, requestBodySanitizer, responseBodySanitizer) => {
const { httpRequest, httpResponse } = expectation;
const responseHeaders = {
'Content-Type': 'application/json; charset=utf-8',
};
Object.keys(httpResponse.headers)
.filter(key => !HEADERS_TO_IGNORE.includes(key.toLowerCase()))
.forEach(key => {
responseHeaders[key] = httpResponse.headers[key][0];
});
let queryString;
if (httpRequest.queryStringParameters) {
const { queryStringParameters } = httpRequest;
queryString = Object.keys(queryStringParameters)
.map(key => `${key}=${queryStringParameters[key]}`)
.join('&');
}
const body = requestBodySanitizer(httpRequest);
const responseBody = responseBodySanitizer(httpRequest, httpResponse);
const cypressRouteOptions = {
body,
method: httpRequest.method,
url: queryString ? `${httpRequest.path}?${queryString}` : httpRequest.path,
headers: responseHeaders,
response: responseBody,
status: httpResponse.statusCode,
};
return cypressRouteOptions;
};
function getGitClient(repoDir) {
const git = simpleGit(repoDir).env({ ...process.env, GIT_SSH_COMMAND, GIT_SSL_NO_VERIFY });
return git;
}
module.exports = {
getExpectationsFilename,
transformRecordedData,
getGitClient,
};

View File

@ -0,0 +1,284 @@
const fetch = require('node-fetch');
const {
transformRecordedData: transformGitHub,
setupGitHub,
teardownGitHub,
setupGitHubTest,
teardownGitHubTest,
} = require('./github');
const {
transformRecordedData: transformGitLab,
setupGitLab,
teardownGitLab,
setupGitLabTest,
teardownGitLabTest,
} = require('./gitlab');
const { getGitClient } = require('./common');
function getEnvs() {
const {
NETLIFY_API_TOKEN: netlifyApiToken,
GITHUB_REPO_TOKEN: githubToken,
GITLAB_REPO_TOKEN: gitlabToken,
NETLIFY_INSTALLATION_ID: installationId,
} = process.env;
if (!netlifyApiToken) {
throw new Error(
'Please set NETLIFY_API_TOKEN, GITHUB_REPO_TOKEN, GITLAB_REPO_TOKEN, NETLIFY_INSTALLATION_ID environment variables',
);
}
return { netlifyApiToken, githubToken, gitlabToken, installationId };
}
const apiRoot = 'https://api.netlify.com/api/v1/';
async function get(netlifyApiToken, path) {
const response = await fetch(`${apiRoot}${path}`, {
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${netlifyApiToken}`,
},
}).then(res => res.json());
return response;
}
async function post(netlifyApiToken, path, payload) {
const response = await fetch(`${apiRoot}${path}`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${netlifyApiToken}`,
},
...(payload ? { body: JSON.stringify(payload) } : {}),
}).then(res => res.json());
return response;
}
async function del(netlifyApiToken, path) {
const response = await fetch(`${apiRoot}${path}`, {
method: 'DELETE',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${netlifyApiToken}`,
},
}).then(res => res.text());
return response;
}
async function createSite(netlifyApiToken, payload) {
return post(netlifyApiToken, 'sites', payload);
}
async function enableIdentity(netlifyApiToken, siteId) {
return post(netlifyApiToken, `sites/${siteId}/identity`, {});
}
async function enableGitGateway(netlifyApiToken, siteId, provider, token, repo) {
return post(netlifyApiToken, `sites/${siteId}/services/git/instances`, {
[provider]: {
repo,
access_token: token,
},
});
}
async function enableLargeMedia(netlifyApiToken, siteId) {
return post(netlifyApiToken, `sites/${siteId}/services/large-media/instances`, {});
}
async function waitForDeploys(netlifyApiToken, siteId) {
for (let i = 0; i < 10; i++) {
const deploys = await get(netlifyApiToken, `sites/${siteId}/deploys`);
if (deploys.some(deploy => deploy.state === 'ready')) {
console.info('Deploy finished for site:', siteId);
return;
}
console.info('Waiting on deploy of site:', siteId);
await new Promise(resolve => setTimeout(resolve, 30 * 1000));
}
console.info('Timed out waiting on deploy of site:', siteId);
}
async function createUser(netlifyApiToken, siteUrl, email, password) {
const response = await fetch(`${siteUrl}/.netlify/functions/create-user`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${netlifyApiToken}`,
},
body: JSON.stringify({ email, password }),
});
if (response.ok) {
console.info('User created successfully');
} else {
throw new Error('Failed to create user');
}
}
const netlifySiteURL = 'https://fake-site-url.netlify.com/';
const email = 'static@p-m.si';
const password = '12345678';
const backendName = 'git-gateway';
const methods = {
github: {
setup: setupGitHub,
teardown: teardownGitHub,
setupTest: setupGitHubTest,
teardownTest: teardownGitHubTest,
transformData: transformGitHub,
createSite: (netlifyApiToken, result) => {
const { installationId } = getEnvs();
return createSite(netlifyApiToken, {
repo: {
provider: 'github',
installation_id: installationId,
repo: `${result.owner}/${result.repo}`,
},
});
},
token: () => getEnvs().githubToken,
},
gitlab: {
setup: setupGitLab,
teardown: teardownGitLab,
setupTest: setupGitLabTest,
teardownTest: teardownGitLabTest,
transformData: transformGitLab,
createSite: async (netlifyApiToken, result) => {
const { id, public_key } = await post(netlifyApiToken, 'deploy_keys');
const { gitlabToken } = getEnvs();
const project = `${result.owner}/${result.repo}`;
await fetch(`https://gitlab.com/api/v4/projects/${encodeURIComponent(project)}/deploy_keys`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${gitlabToken}`,
},
body: JSON.stringify({ title: 'Netlify Deploy Key', key: public_key, can_push: false }),
}).then(res => res.json());
const site = await createSite(netlifyApiToken, {
account_slug: result.owner,
repo: {
provider: 'gitlab',
repo: `${result.owner}/${result.repo}`,
deploy_key_id: id,
},
});
await fetch(`https://gitlab.com/api/v4/projects/${encodeURIComponent(project)}/hooks`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Authorization: `Bearer ${gitlabToken}`,
},
body: JSON.stringify({
url: 'https://api.netlify.com/hooks/gitlab',
push_events: true,
merge_requests_events: true,
enable_ssl_verification: true,
}),
}).then(res => res.json());
return site;
},
token: () => getEnvs().gitlabToken,
},
};
async function setupGitGateway(options) {
const { provider, ...rest } = options;
const result = await methods[provider].setup(rest);
const { netlifyApiToken } = getEnvs();
console.info(`Creating Netlify Site for provider: ${provider}`);
let site_id, ssl_url;
try {
({ site_id, ssl_url } = await methods[provider].createSite(netlifyApiToken, result));
} catch (e) {
console.error(e);
throw e;
}
console.info('Enabling identity for site:', site_id);
await enableIdentity(netlifyApiToken, site_id);
console.info('Enabling git gateway for site:', site_id);
const token = methods[provider].token();
await enableGitGateway(
netlifyApiToken,
site_id,
provider,
token,
`${result.owner}/${result.repo}`,
);
console.info('Enabling large media for site:', site_id);
await enableLargeMedia(netlifyApiToken, site_id);
const git = getGitClient(result.tempDir);
await git.raw([
'config',
'-f',
'.lfsconfig',
'lfs.url',
`https://${site_id}.netlify.com/.netlify/large-media`,
]);
await git.addConfig('commit.gpgsign', 'false');
await git.add('.lfsconfig');
await git.commit('add .lfsconfig');
await git.push('origin', 'main');
await waitForDeploys(netlifyApiToken, site_id);
console.info('Creating user for site:', site_id, 'with email:', email);
try {
await createUser(netlifyApiToken, ssl_url, email, password);
} catch (e) {
console.error(e);
}
return {
...result,
user: {
...result.user,
backendName,
netlifySiteURL: ssl_url,
email,
password,
},
site_id,
ssl_url,
provider,
};
}
async function teardownGitGateway(taskData) {
const { netlifyApiToken } = getEnvs();
const { site_id } = taskData;
console.info('Deleting Netlify site:', site_id);
await del(netlifyApiToken, `sites/${site_id}`);
return methods[taskData.provider].teardown(taskData);
}
async function setupGitGatewayTest(taskData) {
return methods[taskData.provider].setupTest(taskData);
}
async function teardownGitGatewayTest(taskData) {
return methods[taskData.provider].teardownTest(taskData, {});
}
module.exports = {
setupGitGateway,
teardownGitGateway,
setupGitGatewayTest,
teardownGitGatewayTest,
};

426
cypress/plugins/github.js Normal file
View File

@ -0,0 +1,426 @@
const { Octokit } = require('@octokit/rest');
const fs = require('fs-extra');
const path = require('path');
const {
getExpectationsFilename,
transformRecordedData: transformData,
getGitClient,
} = require('./common');
const { updateConfig } = require('../utils/config');
const { escapeRegExp } = require('../utils/regexp');
const { merge } = require('lodash');
const GITHUB_REPO_OWNER_SANITIZED_VALUE = 'owner';
const GITHUB_REPO_NAME_SANITIZED_VALUE = 'repo';
const GITHUB_REPO_TOKEN_SANITIZED_VALUE = 'fakeToken';
const GITHUB_OPEN_AUTHORING_OWNER_SANITIZED_VALUE = 'forkOwner';
const GITHUB_OPEN_AUTHORING_TOKEN_SANITIZED_VALUE = 'fakeForkToken';
const FAKE_OWNER_USER = {
login: 'owner',
id: 1,
avatar_url: 'https://avatars1.githubusercontent.com/u/7892489?v=4',
name: 'owner',
};
const FAKE_FORK_OWNER_USER = {
login: 'forkOwner',
id: 2,
avatar_url: 'https://avatars1.githubusercontent.com/u/9919?s=200&v=4',
name: 'forkOwner',
};
function getGitHubClient(token) {
const client = new Octokit({
auth: `token ${token}`,
baseUrl: 'https://api.github.com',
});
return client;
}
function getEnvs() {
const {
GITHUB_REPO_OWNER: owner,
GITHUB_REPO_NAME: repo,
GITHUB_REPO_TOKEN: token,
GITHUB_OPEN_AUTHORING_OWNER: forkOwner,
GITHUB_OPEN_AUTHORING_TOKEN: forkToken,
} = process.env;
if (!owner || !repo || !token || !forkOwner || !forkToken) {
throw new Error(
'Please set GITHUB_REPO_OWNER, GITHUB_REPO_NAME, GITHUB_REPO_TOKEN, GITHUB_OPEN_AUTHORING_OWNER, GITHUB_OPEN_AUTHORING_TOKEN environment variables',
);
}
return { owner, repo, token, forkOwner, forkToken };
}
async function prepareTestGitHubRepo() {
const { owner, repo, token } = getEnvs();
// postfix a random string to avoid collisions
const postfix = Math.random().toString(32).slice(2);
const testRepoName = `${repo}-${Date.now()}-${postfix}`;
const client = getGitHubClient(token);
console.info('Creating repository', testRepoName);
await client.repos.createForAuthenticatedUser({
name: testRepoName,
});
const tempDir = path.join('.temp', testRepoName);
await fs.remove(tempDir);
let git = getGitClient();
const repoUrl = `git@github.com:${owner}/${repo}.git`;
console.info('Cloning repository', repoUrl);
await git.clone(repoUrl, tempDir);
git = getGitClient(tempDir);
console.info('Pushing to new repository', testRepoName);
await git.removeRemote('origin');
await git.addRemote(
'origin',
`https://${token}:x-oauth-basic@github.com/${owner}/${testRepoName}`,
);
await git.push(['-u', 'origin', 'main']);
return { owner, repo: testRepoName, tempDir };
}
async function getAuthenticatedUser(token) {
const client = getGitHubClient(token);
const { data: user } = await client.users.getAuthenticated();
return { ...user, token, backendName: 'github' };
}
async function getUser() {
const { token } = getEnvs();
return getAuthenticatedUser(token);
}
async function getForkUser() {
const { forkToken } = getEnvs();
return getAuthenticatedUser(forkToken);
}
async function deleteRepositories({ owner, repo, tempDir }) {
const { forkOwner, token, forkToken } = getEnvs();
const errorHandler = e => {
if (e.status !== 404) {
throw e;
}
};
console.info('Deleting repository', `${owner}/${repo}`);
await fs.remove(tempDir);
let client = getGitHubClient(token);
await client.repos
.delete({
owner,
repo,
})
.catch(errorHandler);
console.info('Deleting forked repository', `${forkOwner}/${repo}`);
client = getGitHubClient(forkToken);
await client.repos
.delete({
owner: forkOwner,
repo,
})
.catch(errorHandler);
}
async function batchRequests(items, batchSize, func) {
while (items.length > 0) {
const batch = items.splice(0, batchSize);
await Promise.all(batch.map(func));
await new Promise(resolve => setTimeout(resolve, 2500));
}
}
async function resetOriginRepo({ owner, repo, tempDir }) {
console.info('Resetting origin repo:', `${owner}/${repo}`);
const { token } = getEnvs();
const client = getGitHubClient(token);
const { data: prs } = await client.pulls.list({
repo,
owner,
state: 'open',
});
const numbers = prs.map(pr => pr.number);
console.info('Closing prs:', numbers);
await batchRequests(numbers, 10, async pull_number => {
await client.pulls.update({
owner,
repo,
pull_number,
state: 'closed',
});
});
const { data: branches } = await client.repos.listBranches({ owner, repo });
const refs = branches.filter(b => b.name !== 'main').map(b => `heads/${b.name}`);
console.info('Deleting refs', refs);
await batchRequests(refs, 10, async ref => {
await client.git.deleteRef({
owner,
repo,
ref,
});
});
console.info('Resetting main');
const git = getGitClient(tempDir);
await git.push(['--force', 'origin', 'main']);
console.info('Done resetting origin repo:', `${owner}/${repo}`);
}
async function resetForkedRepo({ repo }) {
const { forkToken, forkOwner } = getEnvs();
const client = getGitHubClient(forkToken);
const { data: repos } = await client.repos.list();
if (repos.some(r => r.name === repo)) {
console.info('Resetting forked repo:', `${forkOwner}/${repo}`);
const { data: branches } = await client.repos.listBranches({ owner: forkOwner, repo });
const refs = branches.filter(b => b.name !== 'main').map(b => `heads/${b.name}`);
console.info('Deleting refs', refs);
await Promise.all(
refs.map(ref =>
client.git.deleteRef({
owner: forkOwner,
repo,
ref,
}),
),
);
console.info('Done resetting forked repo:', `${forkOwner}/${repo}`);
}
}
async function resetRepositories({ owner, repo, tempDir }) {
await resetOriginRepo({ owner, repo, tempDir });
await resetForkedRepo({ repo });
}
async function setupGitHub(options) {
console.info('Running tests - live data will be used!');
const [user, forkUser, repoData] = await Promise.all([
getUser(),
getForkUser(),
prepareTestGitHubRepo(),
]);
await updateConfig(config => {
merge(config, options, {
backend: {
repo: `${repoData.owner}/${repoData.repo}`,
},
});
});
return { ...repoData, user, forkUser };
}
async function teardownGitHub(taskData) {
await deleteRepositories(taskData);
return null;
}
async function setupGitHubTest(taskData) {
await resetRepositories(taskData);
return null;
}
const sanitizeString = (
str,
{ owner, repo, token, forkOwner, forkToken, ownerName, forkOwnerName },
) => {
let replaced = str
.replace(new RegExp(escapeRegExp(forkOwner), 'g'), GITHUB_OPEN_AUTHORING_OWNER_SANITIZED_VALUE)
.replace(new RegExp(escapeRegExp(forkToken), 'g'), GITHUB_OPEN_AUTHORING_TOKEN_SANITIZED_VALUE)
.replace(new RegExp(escapeRegExp(owner), 'g'), GITHUB_REPO_OWNER_SANITIZED_VALUE)
.replace(new RegExp(escapeRegExp(repo), 'g'), GITHUB_REPO_NAME_SANITIZED_VALUE)
.replace(new RegExp(escapeRegExp(token), 'g'), GITHUB_REPO_TOKEN_SANITIZED_VALUE)
.replace(
new RegExp('https://avatars\\d+\\.githubusercontent\\.com/u/\\d+?\\?v=\\d', 'g'),
`${FAKE_OWNER_USER.avatar_url}`,
);
if (ownerName) {
replaced = replaced.replace(new RegExp(escapeRegExp(ownerName), 'g'), FAKE_OWNER_USER.name);
}
if (forkOwnerName) {
replaced = replaced.replace(
new RegExp(escapeRegExp(forkOwnerName), 'g'),
FAKE_FORK_OWNER_USER.name,
);
}
return replaced;
};
const transformRecordedData = (expectation, toSanitize) => {
const requestBodySanitizer = httpRequest => {
let body;
if (httpRequest.body && httpRequest.body.type === 'JSON' && httpRequest.body.json) {
const bodyObject =
typeof httpRequest.body.json === 'string'
? JSON.parse(httpRequest.body.json)
: httpRequest.body.json;
if (bodyObject.encoding === 'base64') {
// sanitize encoded data
const decodedBody = Buffer.from(bodyObject.content, 'base64').toString('binary');
const sanitizedContent = sanitizeString(decodedBody, toSanitize);
const sanitizedEncodedContent = Buffer.from(sanitizedContent, 'binary').toString('base64');
bodyObject.content = sanitizedEncodedContent;
body = JSON.stringify(bodyObject);
} else {
body = JSON.stringify(bodyObject);
}
} else if (httpRequest.body && httpRequest.body.type === 'STRING' && httpRequest.body.string) {
body = httpRequest.body.string;
} else if (httpRequest.body) {
const str =
typeof httpRequest.body !== 'string' ? JSON.stringify(httpRequest.body) : httpRequest.body;
body = sanitizeString(str, toSanitize);
}
return body;
};
const responseBodySanitizer = (httpRequest, httpResponse) => {
let responseBody = null;
if (httpResponse.body && httpResponse.body.string) {
responseBody = httpResponse.body.string;
} else if (
httpResponse.body &&
httpResponse.body.type === 'BINARY' &&
httpResponse.body.base64Bytes
) {
responseBody = {
encoding: 'base64',
content: httpResponse.body.base64Bytes,
};
} else if (httpResponse.body && httpResponse.body.json) {
responseBody = JSON.stringify(httpResponse.body.json);
} else {
responseBody =
typeof httpResponse.body === 'string'
? httpResponse.body
: httpResponse.body && JSON.stringify(httpResponse.body);
}
// replace recorded user with fake one
if (
responseBody &&
httpRequest.path === '/user' &&
httpRequest.headers.host.includes('api.github.com')
) {
const parsed = JSON.parse(responseBody);
if (parsed.login === toSanitize.forkOwner) {
responseBody = JSON.stringify(FAKE_FORK_OWNER_USER);
} else {
responseBody = JSON.stringify(FAKE_OWNER_USER);
}
}
return responseBody;
};
const cypressRouteOptions = transformData(
expectation,
requestBodySanitizer,
responseBodySanitizer,
);
return cypressRouteOptions;
};
const defaultOptions = {
transformRecordedData,
};
async function teardownGitHubTest(taskData, { transformRecordedData } = defaultOptions) {
await resetRepositories(taskData);
return null;
}
async function seedGitHubRepo(taskData) {
const { owner, token } = getEnvs();
const client = getGitHubClient(token);
const repo = taskData.repo;
try {
console.info('Getting main branch');
const { data: main } = await client.repos.getBranch({
owner,
repo,
branch: 'main',
});
const prCount = 120;
const prs = new Array(prCount).fill(0).map((v, i) => i);
const batchSize = 5;
await batchRequests(prs, batchSize, async i => {
const branch = `seed_branch_${i}`;
console.info(`Creating branch ${branch}`);
await client.git.createRef({
owner,
repo,
ref: `refs/heads/${branch}`,
sha: main.commit.sha,
});
const path = `seed/file_${i}`;
console.info(`Creating file ${path}`);
await client.repos.createOrUpdateFile({
owner,
repo,
branch,
content: Buffer.from(`Seed File ${i}`).toString('base64'),
message: `Create seed file ${i}`,
path,
});
const title = `Non CMS Pull Request ${i}`;
console.info(`Creating PR ${title}`);
await client.pulls.create({
owner,
repo,
base: 'main',
head: branch,
title,
});
});
} catch (e) {
console.error(e);
throw e;
}
return null;
}
module.exports = {
transformRecordedData,
setupGitHub,
teardownGitHub,
setupGitHubTest,
teardownGitHubTest,
seedGitHubRepo,
};

275
cypress/plugins/gitlab.js Normal file
View File

@ -0,0 +1,275 @@
const { Gitlab } = require('gitlab');
const fs = require('fs-extra');
const path = require('path');
const { updateConfig } = require('../utils/config');
const { escapeRegExp } = require('../utils/regexp');
const {
getExpectationsFilename,
transformRecordedData: transformData,
getGitClient,
} = require('./common');
const { merge } = require('lodash');
const GITLAB_REPO_OWNER_SANITIZED_VALUE = 'owner';
const GITLAB_REPO_NAME_SANITIZED_VALUE = 'repo';
const GITLAB_REPO_TOKEN_SANITIZED_VALUE = 'fakeToken';
const FAKE_OWNER_USER = {
id: 1,
name: 'owner',
username: 'owner',
avatar_url: 'https://avatars1.githubusercontent.com/u/7892489?v=4',
email: 'owner@email.com',
login: 'owner',
};
function getGitLabClient(token) {
const client = new Gitlab({
token,
});
return client;
}
function getEnvs() {
const {
GITLAB_REPO_OWNER: owner,
GITLAB_REPO_NAME: repo,
GITLAB_REPO_TOKEN: token,
} = process.env;
if (!owner || !repo || !token) {
throw new Error(
'Please set GITLAB_REPO_OWNER, GITLAB_REPO_NAME, GITLAB_REPO_TOKEN environment variables',
);
}
return { owner, repo, token };
}
async function prepareTestGitLabRepo() {
const { owner, repo, token } = getEnvs();
// postfix a random string to avoid collisions
const postfix = Math.random().toString(32).slice(2);
const testRepoName = `${repo}-${Date.now()}-${postfix}`;
const client = getGitLabClient(token);
console.info('Creating repository', testRepoName);
await client.Projects.create({
name: testRepoName,
lfs_enabled: false,
});
const tempDir = path.join('.temp', testRepoName);
await fs.remove(tempDir);
let git = getGitClient();
const repoUrl = `git@gitlab.com:${owner}/${repo}.git`;
console.info('Cloning repository', repoUrl);
await git.clone(repoUrl, tempDir);
git = getGitClient(tempDir);
console.info('Pushing to new repository', testRepoName);
await git.removeRemote('origin');
await git.addRemote('origin', `https://oauth2:${token}@gitlab.com/${owner}/${testRepoName}`);
await git.push(['-u', 'origin', 'main']);
await client.ProtectedBranches.unprotect(`${owner}/${testRepoName}`, 'main');
return { owner, repo: testRepoName, tempDir };
}
async function getAuthenticatedUser(token) {
const client = getGitLabClient(token);
const user = await client.Users.current();
return { ...user, token, backendName: 'gitlab' };
}
async function getUser() {
const { token } = getEnvs();
return getAuthenticatedUser(token);
}
async function deleteRepositories({ owner, repo, tempDir }) {
const { token } = getEnvs();
const errorHandler = e => {
if (e.status !== 404) {
throw e;
}
};
console.info('Deleting repository', `${owner}/${repo}`);
await fs.remove(tempDir);
const client = getGitLabClient(token);
await client.Projects.remove(`${owner}/${repo}`).catch(errorHandler);
}
async function resetOriginRepo({ owner, repo, tempDir }) {
console.info('Resetting origin repo:', `${owner}/${repo}`);
const { token } = getEnvs();
const client = getGitLabClient(token);
const projectId = `${owner}/${repo}`;
const mergeRequests = await client.MergeRequests.all({
projectId,
state: 'opened',
});
const ids = mergeRequests.map(mr => mr.iid);
console.info('Closing merge requests:', ids);
await Promise.all(
ids.map(id => client.MergeRequests.edit(projectId, id, { state_event: 'close' })),
);
const branches = await client.Branches.all(projectId);
const toDelete = branches.filter(b => b.name !== 'main').map(b => b.name);
console.info('Deleting branches', toDelete);
await Promise.all(toDelete.map(branch => client.Branches.remove(projectId, branch)));
console.info('Resetting main');
const git = getGitClient(tempDir);
await git.push(['--force', 'origin', 'main']);
console.info('Done resetting origin repo:', `${owner}/${repo}`);
}
async function resetRepositories({ owner, repo, tempDir }) {
await resetOriginRepo({ owner, repo, tempDir });
}
async function setupGitLab(options) {
console.info('Running tests - live data will be used!');
const [user, repoData] = await Promise.all([getUser(), prepareTestGitLabRepo()]);
await updateConfig(config => {
merge(config, options, {
backend: {
repo: `${repoData.owner}/${repoData.repo}`,
},
});
});
return { ...repoData, user };
}
async function teardownGitLab(taskData) {
await deleteRepositories(taskData);
return null;
}
async function setupGitLabTest(taskData) {
await resetRepositories(taskData);
return null;
}
const sanitizeString = (str, { owner, repo, token, ownerName }) => {
let replaced = str
.replace(new RegExp(escapeRegExp(owner), 'g'), GITLAB_REPO_OWNER_SANITIZED_VALUE)
.replace(new RegExp(escapeRegExp(repo), 'g'), GITLAB_REPO_NAME_SANITIZED_VALUE)
.replace(new RegExp(escapeRegExp(token), 'g'), GITLAB_REPO_TOKEN_SANITIZED_VALUE)
.replace(
new RegExp('https://secure.gravatar.+?/u/.+?v=\\d', 'g'),
`${FAKE_OWNER_USER.avatar_url}`,
);
if (ownerName) {
replaced = replaced.replace(new RegExp(escapeRegExp(ownerName), 'g'), FAKE_OWNER_USER.name);
}
return replaced;
};
const transformRecordedData = (expectation, toSanitize) => {
const requestBodySanitizer = httpRequest => {
let body;
if (httpRequest.body && httpRequest.body.type === 'JSON' && httpRequest.body.json) {
const bodyObject =
typeof httpRequest.body.json === 'string'
? JSON.parse(httpRequest.body.json)
: httpRequest.body.json;
if (bodyObject.encoding === 'base64') {
// sanitize encoded data
const decodedBody = Buffer.from(bodyObject.content, 'base64').toString('binary');
const sanitizedContent = sanitizeString(decodedBody, toSanitize);
const sanitizedEncodedContent = Buffer.from(sanitizedContent, 'binary').toString('base64');
bodyObject.content = sanitizedEncodedContent;
body = JSON.stringify(bodyObject);
} else {
body = JSON.stringify(bodyObject);
}
} else if (httpRequest.body && httpRequest.body.type === 'STRING' && httpRequest.body.string) {
body = sanitizeString(httpRequest.body.string, toSanitize);
} else if (httpRequest.body) {
const str =
typeof httpRequest.body !== 'string' ? JSON.stringify(httpRequest.body) : httpRequest.body;
body = sanitizeString(str, toSanitize);
}
return body;
};
const responseBodySanitizer = (httpRequest, httpResponse) => {
let responseBody = null;
if (httpResponse.body && httpResponse.body.string) {
responseBody = httpResponse.body.string;
} else if (
httpResponse.body &&
httpResponse.body.type === 'BINARY' &&
httpResponse.body.base64Bytes
) {
responseBody = {
encoding: 'base64',
content: httpResponse.body.base64Bytes,
};
} else if (httpResponse.body && httpResponse.body.json) {
responseBody = JSON.stringify(httpResponse.body.json);
} else {
responseBody =
typeof httpResponse.body === 'string'
? httpResponse.body
: httpResponse.body && JSON.stringify(httpResponse.body);
}
// replace recorded user with fake one
if (
responseBody &&
httpRequest.path === '/api/v4/user' &&
httpRequest.headers.host.includes('gitlab.com')
) {
responseBody = JSON.stringify(FAKE_OWNER_USER);
}
return responseBody;
};
const cypressRouteOptions = transformData(
expectation,
requestBodySanitizer,
responseBodySanitizer,
);
return cypressRouteOptions;
};
const defaultOptions = {
transformRecordedData,
};
async function teardownGitLabTest(taskData, { transformRecordedData } = defaultOptions) {
await resetRepositories(taskData);
return null;
}
module.exports = {
transformRecordedData,
setupGitLab,
teardownGitLab,
setupGitLabTest,
teardownGitLabTest,
};

178
cypress/plugins/index.ts Normal file
View File

@ -0,0 +1,178 @@
// ***********************************************************
// This example plugins/index.js can be used to load plugins
//
// You can change the location of this file or turn off loading
// the plugins file with the 'pluginsFile' configuration option.
//
// You can read more here:
// https://on.cypress.io/plugins-guide
// ***********************************************************
// This function is called when a project is opened or re-opened (e.g. due to
// the project's config changing)
import 'dotenv/config';
import { addMatchImageSnapshotPlugin } from '@simonsmith/cypress-image-snapshot/plugin';
import merge from 'lodash/merge';
// const { setupGitHub, teardownGitHub, setupGitHubTest, teardownGitHubTest, seedGitHubRepo } = require("./github");
// const { setupGitGateway, teardownGitGateway, setupGitGatewayTest, teardownGitGatewayTest } = require("./gitGateway");
// const { setupGitLab, teardownGitLab, setupGitLabTest, teardownGitLabTest } = require("./gitlab");
// const { setupBitBucket, teardownBitBucket, setupBitBucketTest, teardownBitBucketTest } = require("./bitbucket");
// const { setupProxy, teardownProxy, setupProxyTest, teardownProxyTest } = require("./proxy");
import { setupTestBackend } from './testBackend';
import { copyBackendFiles, switchVersion, updateConfig } from '../utils/config';
import type { Config } from '@staticcms/core/interface';
import type {
SeedRepoProps,
SetupBackendProps,
SetupBackendResponse,
SetupBackendTestProps,
TeardownBackendProps,
TeardownBackendTestProps,
} from '../interface';
export default async (on: Cypress.PluginEvents) => {
// `on` is used to hook into various events Cypress emits
on('task', {
async setupBackend({ backend, options }: SetupBackendProps): Promise<SetupBackendResponse> {
console.info('Preparing environment for backend', backend);
await copyBackendFiles(backend);
let result = null;
switch (backend) {
// case "github":
// result = await setupGitHub(options);
// break;
// case "git-gateway":
// result = await setupGitGateway(options);
// break;
// case "gitlab":
// result = await setupGitLab(options);
// break;
// case "bitbucket":
// result = await setupBitBucket(options);
// break;
// case "proxy":
// result = await setupProxy(options);
// break;
case 'test':
result = await setupTestBackend(options);
break;
}
return result;
},
async teardownBackend({ backend }: TeardownBackendProps): Promise<null> {
console.info('Tearing down backend', backend);
switch (
backend
// case "github":
// await teardownGitHub(taskData);
// break;
// case "git-gateway":
// await teardownGitGateway(taskData);
// break;
// case "gitlab":
// await teardownGitLab(taskData);
// break;
// case "bitbucket":
// await teardownBitBucket(taskData);
// break;
// case "proxy":
// await teardownProxy(taskData);
// break;
) {
}
console.info('Restoring defaults');
await copyBackendFiles('test');
return null;
},
async setupBackendTest({ backend, testName }: SetupBackendTestProps): Promise<null> {
console.info(`Setting up single test '${testName}' for backend`, backend);
switch (
backend
// case "github":
// await setupGitHubTest(taskData);
// break;
// case "git-gateway":
// await setupGitGatewayTest(taskData);
// break;
// case "gitlab":
// await setupGitLabTest(taskData);
// break;
// case "bitbucket":
// await setupBitBucketTest(taskData);
// break;
// case "proxy":
// await setupProxyTest(taskData);
// break;
) {
}
return null;
},
async teardownBackendTest({ backend, testName }: TeardownBackendTestProps): Promise<null> {
console.info(`Tearing down single test '${testName}' for backend`, backend);
switch (
backend
// case "github":
// await teardownGitHubTest(taskData);
// break;
// case "git-gateway":
// await teardownGitGatewayTest(taskData);
// break;
// case "gitlab":
// await teardownGitLabTest(taskData);
// break;
// case "bitbucket":
// await teardownBitBucketTest(taskData);
// break;
// case "proxy":
// await teardownProxyTest(taskData);
// break;
) {
}
return null;
},
async seedRepo({ backend }: SeedRepoProps): Promise<null> {
console.info(`Seeding repository for backend`, backend);
switch (
backend
// case "github":
// await seedGitHubRepo(taskData);
// break;
) {
}
return null;
},
async switchToVersion(taskData) {
const { version } = taskData;
console.info(`Switching CMS to version '${version}'`);
await switchVersion(version);
return null;
},
async updateConfig(config: Partial<Config>) {
await updateConfig(current => {
merge(current, config);
});
return null;
},
});
addMatchImageSnapshotPlugin(on);
};

105
cypress/plugins/proxy.js Normal file
View File

@ -0,0 +1,105 @@
const fs = require('fs-extra');
const path = require('path');
const { spawn } = require('child_process');
const { merge } = require('lodash');
const { updateConfig } = require('../utils/config');
const { getGitClient } = require('./common');
const initRepo = async dir => {
await fs.remove(dir);
await fs.mkdirp(dir);
const git = getGitClient(dir);
await git.init({ '--initial-branch': 'main' });
await git.addConfig('user.email', 'cms-cypress-test@netlify.com');
await git.addConfig('user.name', 'cms-cypress-test');
const readme = 'README.md';
await fs.writeFile(path.join(dir, readme), '');
await git.add(readme);
await git.commit('initial commit', readme, { '--no-verify': true, '--no-gpg-sign': true });
};
const startServer = async (repoDir, mode) => {
const tsNode = path.join(__dirname, '..', '..', 'node_modules', '.bin', 'ts-node');
const serverDir = path.join(__dirname, '..', '..', 'packages', 'static-server');
const distIndex = path.join(serverDir, 'dist', 'index.js');
const tsIndex = path.join(serverDir, 'src', 'index.ts');
const port = 8082;
const env = {
...process.env,
GIT_REPO_DIRECTORY: path.resolve(repoDir),
PORT: port,
MODE: mode,
};
console.info(`Starting proxy server on port '${port}' with mode ${mode}`);
if (await fs.pathExists(distIndex)) {
serverProcess = spawn('node', [distIndex], { env, cwd: serverDir });
} else {
serverProcess = spawn(tsNode, ['--files', tsIndex], { env, cwd: serverDir });
}
return new Promise((resolve, reject) => {
serverProcess.stdout.on('data', data => {
const message = data.toString().trim();
console.info(`server:stdout: ${message}`);
if (message.includes('Static CMS Proxy Server listening on port')) {
resolve(serverProcess);
}
});
serverProcess.stderr.on('data', data => {
console.error(`server:stderr: ${data.toString().trim()}`);
reject(data.toString());
});
});
};
let serverProcess;
async function setupProxy(options) {
const postfix = Math.random().toString(32).slice(2);
const testRepoName = `proxy-test-repo-${Date.now()}-${postfix}`;
const tempDir = path.join('.temp', testRepoName);
const { mode, ...rest } = options;
await updateConfig(config => {
merge(config, rest);
});
return { tempDir, mode };
}
async function teardownProxy(taskData) {
if (serverProcess) {
serverProcess.kill();
}
await fs.remove(taskData.tempDir);
return null;
}
async function setupProxyTest(taskData) {
await initRepo(taskData.tempDir);
serverProcess = await startServer(taskData.tempDir, taskData.mode);
return null;
}
async function teardownProxyTest(taskData) {
if (serverProcess) {
serverProcess.kill();
}
await fs.remove(taskData.tempDir);
return null;
}
module.exports = {
setupProxy,
teardownProxy,
setupProxyTest,
teardownProxyTest,
};

View File

@ -0,0 +1,13 @@
import merge from 'lodash/merge';
import { updateConfig } from '../utils/config';
import type { Config } from '@staticcms/core/interface';
import type { SetupBackendResponse } from '../interface';
export async function setupTestBackend(options: Partial<Config>): Promise<SetupBackendResponse> {
await updateConfig(current => {
merge(current, options);
});
return null;
}

41
cypress/run.mjs Normal file
View File

@ -0,0 +1,41 @@
import execa from 'execa';
import { globby } from 'globby';
async function runCypress() {
if (process.env.IS_FORK === 'true') {
const machineIndex = parseInt(process.env.MACHINE_INDEX);
const machineCount = parseInt(process.env.MACHINE_COUNT);
const specs = await globby(['cypress/integration/*spec*.js']);
const specsPerMachine = Math.floor(specs.length / machineCount);
const start = (machineIndex - 1) * specsPerMachine;
const machineSpecs =
machineIndex === machineCount
? specs.slice(start)
: specs.slice(start, start + specsPerMachine);
await execa(
'cypress',
['run', '--browser', 'chrome', '--headless', '--spec', machineSpecs.join(',')],
{ stdio: 'inherit', preferLocal: true },
);
} else {
await execa(
'cypress',
[
'run',
'--browser',
'chrome',
'--headless',
'--record',
'--parallel',
'--ci-build-id',
process.env.GITHUB_SHA,
'--group',
'GitHub CI',
],
{ stdio: 'inherit', preferLocal: true },
);
}
}
runCypress();

Some files were not shown because too many files have changed in this diff Show More