]> git.apps.os.sepia.ceph.com Git - ceph.git/commitdiff
mgr/dashboard: run cephadm-backend e2e tests with KCLI 42243/head
authorAlfonso Martínez <almartin@redhat.com>
Mon, 19 Jul 2021 07:57:26 +0000 (09:57 +0200)
committerAlfonso Martínez <almartin@redhat.com>
Mon, 19 Jul 2021 07:57:26 +0000 (09:57 +0200)
Fixes: https://tracker.ceph.com/issues/51300
Signed-off-by: Alfonso Martínez <almartin@redhat.com>
(cherry picked from commit 5c03b49c4da55cf8d0c679ecb2c58182e4d3361a)

Conflicts:
    - Added content in HACKING.rst as dash-devel.rst does not exist in octopus:
    doc/dev/developer_guide/dash-devel.rst
    src/pybind/mgr/dashboard/HACKING.rst
    - Adapted code to octopus branch in the following files due to branch divergence:
    src/pybind/mgr/dashboard/frontend/cypress.json
    src/pybind/mgr/dashboard/frontend/cypress/integration/cluster/configuration.e2e-spec.ts
    src/pybind/mgr/dashboard/frontend/cypress/integration/cluster/hosts.po.ts
    src/pybind/mgr/dashboard/frontend/cypress/integration/cluster/osds.e2e-spec.ts
    src/pybind/mgr/dashboard/frontend/cypress/integration/orchestrator/workflow/01-hosts.e2e-spec.ts
    src/pybind/mgr/dashboard/frontend/cypress/integration/page-helper.po.ts
    src/pybind/mgr/dashboard/frontend/cypress/integration/ui/dashboard.e2e-spec.ts

src/pybind/mgr/dashboard/HACKING.rst
src/pybind/mgr/dashboard/ci/cephadm/bootstrap-cluster.sh [new file with mode: 0755]
src/pybind/mgr/dashboard/ci/cephadm/ceph_cluster.yml [new file with mode: 0755]
src/pybind/mgr/dashboard/ci/cephadm/run-cephadm-e2e-tests.sh [new file with mode: 0755]
src/pybind/mgr/dashboard/frontend/cypress.json
src/pybind/mgr/dashboard/frontend/cypress/integration/cluster/configuration.e2e-spec.ts
src/pybind/mgr/dashboard/frontend/cypress/integration/cluster/hosts.po.ts
src/pybind/mgr/dashboard/frontend/cypress/integration/cluster/osds.e2e-spec.ts
src/pybind/mgr/dashboard/frontend/cypress/integration/orchestrator/workflow/01-hosts.e2e-spec.ts [new file with mode: 0644]
src/pybind/mgr/dashboard/frontend/cypress/integration/page-helper.po.ts
src/pybind/mgr/dashboard/frontend/cypress/integration/ui/dashboard.e2e-spec.ts

index 66e42009be36005778abd39dbbd9bb303a5c3197..cd841d20797ef4537e237a77762b1ca578823428 100644 (file)
@@ -183,6 +183,26 @@ Note:
   When using docker, as your device, you might need to run the script with sudo
   permissions.
 
+run-cephadm-e2e-tests.sh
+........................
+
+``run-cephadm-e2e-tests.sh`` runs a subset of E2E tests to verify that the Dashboard and cephadm as
+Orchestrator backend behave correctly.
+
+Prerequisites: you need to install `KCLI
+<https://kcli.readthedocs.io/en/latest/>`_ in your local machine.
+
+Note:
+  This script is aimed to be run as jenkins job so the cleanup is triggered only in a jenkins
+  environment. In local, the user will shutdown the cluster when desired (i.e. after debugging).
+
+Start E2E tests by running::
+
+  $ cd <your/ceph/repo/dir>
+  $ sudo chown -R $(id -un) src/pybind/mgr/dashboard/frontend/dist src/pybind/mgr/dashboard/frontend/node_modules
+  $ ./src/pybind/mgr/dashboard/ci/cephadm/run-cephadm-e2e-tests.sh
+  $ kcli delete plan -y ceph  # After tests finish.
+
 Other running options
 .....................
 
diff --git a/src/pybind/mgr/dashboard/ci/cephadm/bootstrap-cluster.sh b/src/pybind/mgr/dashboard/ci/cephadm/bootstrap-cluster.sh
new file mode 100755 (executable)
index 0000000..fd836f7
--- /dev/null
@@ -0,0 +1,22 @@
+#!/usr/bin/env bash
+
+export PATH=/root/bin:$PATH
+mkdir /root/bin
+{% if ceph_dev_folder is defined %}
+  cp /mnt/{{ ceph_dev_folder }}/src/cephadm/cephadm /root/bin/cephadm
+{% else %}
+  cd /root/bin
+  curl --silent --remote-name --location https://raw.githubusercontent.com/ceph/ceph/octopus/src/cephadm/cephadm
+{% endif %}
+chmod +x /root/bin/cephadm
+mkdir -p /etc/ceph
+mon_ip=$(ifconfig eth0  | grep 'inet ' | awk '{ print $2}')
+{% if ceph_dev_folder is defined %}
+  cephadm bootstrap --mon-ip $mon_ip --initial-dashboard-password {{ admin_password }} --allow-fqdn-hostname --dashboard-password-noupdate --shared_ceph_folder /mnt/{{ ceph_dev_folder }}
+{% else %}
+  cephadm bootstrap --mon-ip $mon_ip --initial-dashboard-password {{ admin_password }} --allow-fqdn-hostname --dashboard-password-noupdate
+{% endif %}
+fsid=$(cat /etc/ceph/ceph.conf | grep fsid | awk '{ print $3}')
+{% for number in range(1, nodes) %}
+  ssh-copy-id -f -i /etc/ceph/ceph.pub  -o StrictHostKeyChecking=no root@{{ prefix }}-node-0{{ number }}.{{ domain }}
+{% endfor %}
diff --git a/src/pybind/mgr/dashboard/ci/cephadm/ceph_cluster.yml b/src/pybind/mgr/dashboard/ci/cephadm/ceph_cluster.yml
new file mode 100755 (executable)
index 0000000..80273bb
--- /dev/null
@@ -0,0 +1,40 @@
+parameters:
+ nodes: 3
+ pool: default
+ network: default
+ domain: cephlab.com
+ prefix: ceph
+ numcpus: 1
+ memory: 2048
+ image: fedora34
+ notify: false
+ admin_password: password
+ disks:
+ - 15
+ - 5
+
+{% for number in range(0, nodes) %}
+{{ prefix }}-node-0{{ number }}:
+ image: {{ image }}
+ numcpus: {{ numcpus }}
+ memory: {{ memory }}
+ reserveip: true
+ reservedns: true
+ sharedkey: true
+ domain: {{ domain }}
+ nets:
+  - {{ network }}
+ disks: {{ disks }}
+ pool: {{ pool }}
+ {% if ceph_dev_folder is defined %}
+ sharedfolders: [{{ ceph_dev_folder }}]
+ {% endif %}
+ cmds:
+ - dnf -y install python3 chrony lvm2 podman
+ - sed -i "s/SELINUX=enforcing/SELINUX=permissive/" /etc/selinux/config
+ - setenforce 0
+ {% if number == 0 %}
+ scripts:
+  - bootstrap-cluster.sh
+ {% endif %}
+{% endfor %}
diff --git a/src/pybind/mgr/dashboard/ci/cephadm/run-cephadm-e2e-tests.sh b/src/pybind/mgr/dashboard/ci/cephadm/run-cephadm-e2e-tests.sh
new file mode 100755 (executable)
index 0000000..90bfa8d
--- /dev/null
@@ -0,0 +1,81 @@
+#!/usr/bin/env bash
+
+set -ex
+
+cleanup() {
+    if [[ -n "$JENKINS_HOME" ]]; then
+        printf "\n\nStarting cleanup...\n\n"
+        kcli delete plan -y ceph || true
+        sudo podman container prune -f
+        printf "\n\nCleanup completed.\n\n"
+    fi
+}
+
+on_error() {
+    if [ "$1" != "0" ]; then
+        printf "\n\nERROR $1 thrown on line $2\n\n"
+        printf "\n\nCollecting info...\n\n"
+        for vm_id in 0 1 2
+        do
+            local vm="ceph-node-0${vm_id}"
+            printf "\n\nDisplaying journalctl from VM ${vm}:\n\n"
+            kcli ssh -u root -- ${vm} 'journalctl --no-tail --no-pager -t cloud-init' || true
+            printf "\n\nEnd of journalctl from VM ${vm}\n\n"
+            printf "\n\nDisplaying podman logs:\n\n"
+            kcli ssh -u root -- ${vm} 'podman logs --names --since 30s $(podman ps -aq)' || true
+        done
+        printf "\n\nTEST FAILED.\n\n"
+    fi
+}
+
+trap 'on_error $? $LINENO' ERR
+trap 'cleanup $? $LINENO' EXIT
+
+sed -i '/ceph-node-/d' $HOME/.ssh/known_hosts
+
+: ${CEPH_DEV_FOLDER:=${PWD}}
+
+# Required to start dashboard.
+cd ${CEPH_DEV_FOLDER}/src/pybind/mgr/dashboard/frontend
+NG_CLI_ANALYTICS=false npm ci
+npm run build
+
+cd ${CEPH_DEV_FOLDER}
+kcli delete plan -y ceph || true
+kcli create plan -f ./src/pybind/mgr/dashboard/ci/cephadm/ceph_cluster.yml -P ceph_dev_folder=${CEPH_DEV_FOLDER} ceph
+
+while [[ -z $(kcli ssh -u root -- ceph-node-00 'journalctl --no-tail --no-pager -t cloud-init' | grep "Dashboard is now available") ]]; do
+    sleep 30
+    kcli list vm
+    # Uncomment for debugging purposes.
+    #kcli ssh -u root -- ceph-node-00 'podman ps -a'
+    #kcli ssh -u root -- ceph-node-00 'podman logs --names --since 30s $(podman ps -aq)'
+    kcli ssh -u root -- ceph-node-00 'journalctl -n 100 --no-pager -t cloud-init'
+done
+
+cd ${CEPH_DEV_FOLDER}/src/pybind/mgr/dashboard/frontend
+npx cypress info
+
+: ${CYPRESS_BASE_URL:=''}
+: ${CYPRESS_LOGIN_USER:='admin'}
+: ${CYPRESS_LOGIN_PWD:='password'}
+: ${CYPRESS_ARGS:=''}
+
+if [[ -z "${CYPRESS_BASE_URL}" ]]; then
+    CYPRESS_BASE_URL="https://$(kcli info vm ceph-node-00 -f ip -v | sed -e 's/[^0-9.]//'):8443"
+fi
+
+export CYPRESS_BASE_URL CYPRESS_LOGIN_USER CYPRESS_LOGIN_PWD
+
+cypress_run () {
+    local specs="$1"
+    local timeout="$2"
+    local override_config="ignoreTestFiles=*.po.ts,retries=0,testFiles=${specs}"
+
+    if [[ -n "$timeout" ]]; then
+        override_config="${override_config},defaultCommandTimeout=${timeout}"
+    fi
+    npx cypress run ${CYPRESS_ARGS} --browser chrome --headless --config "$override_config"
+}
+
+cypress_run "orchestrator/workflow/*-spec.ts"
index 4f604241edb98ff7c83be41ce65cf4350b3ca2c1..72b5b850f67eef7474613f14ba307e3cd530fe2c 100644 (file)
@@ -1,7 +1,8 @@
 {
   "baseUrl": "http://localhost:4200/",
   "ignoreTestFiles": [
-    "*.po.ts"
+    "*.po.ts",
+    "**/orchestrator/**"
   ],
   "supportFile": "cypress/support/index.ts",
   "video": false,
index 7caf321885be1b7e025688b08ada4b744163e6c8..c7d84146f44ec8013bfcb75fe73ff527942a2ac4 100644 (file)
@@ -52,12 +52,12 @@ describe('Configuration page', () => {
 
     it('should show only modified configurations', () => {
       configuration.filterTable('Modified', 'yes');
-      configuration.getTableFoundCount().should('eq', 2);
+      configuration.getTableCount('found').should('eq', 2);
     });
 
     it('should hide all modified configurations', () => {
       configuration.filterTable('Modified', 'no');
-      configuration.getTableFoundCount().should('gt', 1);
+      configuration.getTableCount('found').should('gt', 1);
     });
   });
 });
index d3e75400bba5a8799f0afd74e83fe7619d537c3c..792759f0780a0eeea6f67ed48562a750fe465d6c 100644 (file)
@@ -1,10 +1,20 @@
 import { PageHelper } from '../page-helper.po';
 
+const pages = {
+  index: { url: '#/hosts', id: 'cd-hosts' },
+  create: { url: '#/hosts/create', id: 'cd-host-form' }
+};
+
 export class HostsPageHelper extends PageHelper {
-  pages = { index: { url: '#/hosts', id: 'cd-hosts' } };
+  pages = pages;
+
+  columnIndex = {
+    hostname: 2,
+    labels: 4
+  };
 
   check_for_host() {
-    this.getTableTotalCount().should('not.be.eq', 0);
+    this.getTableCount('total').should('not.be.eq', 0);
   }
 
   // function that checks all services links work for first
@@ -28,4 +38,82 @@ export class HostsPageHelper extends PageHelper {
         expect(links_tested).gt(0);
       });
   }
+
+  @PageHelper.restrictTo(pages.index.url)
+  clickHostTab(hostname: string, tabName: string) {
+    this.getExpandCollapseElement(hostname).click();
+    cy.get('cd-host-details').within(() => {
+      this.getTab(tabName).click();
+    });
+  }
+
+  @PageHelper.restrictTo(pages.create.url)
+  add(hostname: string, exist?: boolean) {
+    cy.get(`${this.pages.create.id}`).within(() => {
+      cy.get('#hostname').type(hostname);
+      cy.get('cd-submit-button').click();
+    });
+    if (exist) {
+      cy.get('#hostname').should('have.class', 'ng-invalid');
+    } else {
+      // back to host list
+      cy.get(`${this.pages.index.id}`);
+    }
+  }
+
+  @PageHelper.restrictTo(pages.index.url)
+  checkExist(hostname: string, exist: boolean) {
+    this.getTableCell(this.columnIndex.hostname, hostname).should(($elements) => {
+      const hosts = $elements.map((_, el) => el.textContent).get();
+      if (exist) {
+        expect(hosts).to.include(hostname);
+      } else {
+        expect(hosts).to.not.include(hostname);
+      }
+    });
+  }
+
+  @PageHelper.restrictTo(pages.index.url)
+  delete(hostname: string) {
+    super.delete(hostname, this.columnIndex.hostname);
+  }
+
+  // Add or remove labels on a host, then verify labels in the table
+  @PageHelper.restrictTo(pages.index.url)
+  editLabels(hostname: string, labels: string[], add: boolean) {
+    this.getTableCell(this.columnIndex.hostname, hostname).click();
+    this.clickActionButton('edit');
+
+    // add or remove label badges
+    if (add) {
+      cy.get('cd-modal').find('.select-menu-edit').click();
+      for (const label of labels) {
+        cy.contains('cd-modal .badge', new RegExp(`^${label}$`)).should('not.exist');
+        cy.get('.popover-body input').type(`${label}{enter}`);
+      }
+    } else {
+      for (const label of labels) {
+        cy.contains('cd-modal .badge', new RegExp(`^${label}$`))
+          .find('.badge-remove')
+          .click();
+      }
+    }
+    cy.get('cd-modal cd-submit-button').click();
+
+    // Verify labels are added or removed from Labels column
+    // First find row with hostname, then find labels in the row
+    this.getTableCell(this.columnIndex.hostname, hostname)
+      .parent()
+      .find(`datatable-body-cell:nth-child(${this.columnIndex.labels})`)
+      .should(($ele) => {
+        const newLabels = $ele.text().split(' ');
+        for (const label of labels) {
+          if (add) {
+            expect(newLabels).to.include(label);
+          } else {
+            expect(newLabels).to.not.include(label);
+          }
+        }
+      });
+  }
 }
index 1fb76e4d019b3650e7a4286bfe4a49c8e72fe7aa..b8c184ce70bc7ee2a68fd1a44eca4e1384424cbc 100644 (file)
@@ -23,7 +23,7 @@ describe('OSDs page', () => {
 
   describe('check existence of fields on OSD page', () => {
     it('should check that number of rows and count in footer match', () => {
-      osds.getTableTotalCount().then((text) => {
+      osds.getTableCount('total').then((text) => {
         osds.getTableRows().its('length').should('equal', text);
       });
     });
diff --git a/src/pybind/mgr/dashboard/frontend/cypress/integration/orchestrator/workflow/01-hosts.e2e-spec.ts b/src/pybind/mgr/dashboard/frontend/cypress/integration/orchestrator/workflow/01-hosts.e2e-spec.ts
new file mode 100644 (file)
index 0000000..3734f55
--- /dev/null
@@ -0,0 +1,53 @@
+import { HostsPageHelper } from '../../cluster/hosts.po';
+
+describe('Hosts page', () => {
+  const hosts = new HostsPageHelper();
+  const hostnames = ['ceph-node-00.cephlab.com', 'ceph-node-02.cephlab.com'];
+  const addHost = (hostname: string, exist?: boolean) => {
+    hosts.navigateTo('create');
+    hosts.add(hostname, exist);
+    hosts.checkExist(hostname, true);
+  };
+
+  beforeEach(() => {
+    cy.login();
+    Cypress.Cookies.preserveOnce('token');
+    hosts.navigateTo();
+  });
+
+  describe('when Orchestrator is available', () => {
+    it('should display inventory', function () {
+      hosts.clickHostTab(hostnames[0], 'Inventory');
+      cy.get('cd-host-details').within(() => {
+        hosts.getTableCount('total').should('be.gte', 0);
+      });
+    });
+
+    it('should display daemons', function () {
+      hosts.clickHostTab(hostnames[0], 'Daemons');
+      cy.get('cd-host-details').within(() => {
+        hosts.getTableCount('total').should('be.gte', 0);
+      });
+    });
+
+    it('should edit host labels', function () {
+      const labels = ['foo', 'bar'];
+      hosts.editLabels(hostnames[0], labels, true);
+      hosts.editLabels(hostnames[0], labels, false);
+    });
+
+    it('should not add an existing host', function () {
+      hosts.navigateTo('create');
+      hosts.add(hostnames[0], true);
+    });
+
+    it('should add a host', function () {
+      addHost(hostnames[1], false);
+    });
+
+    it('should delete a host and add it back', function () {
+      hosts.delete(hostnames[1]);
+      addHost(hostnames[1], false);
+    });
+  });
+});
index 07d772cc2188dee08a3333871c4a28ff18d6954b..b9212442b1172ef11eb676ec63e325395a500069 100644 (file)
@@ -57,6 +57,7 @@ export abstract class PageHelper {
       this.navigateTo();
       this.getFirstTableCell(name).click();
     }
+    cy.contains('Creating...').should('not.exist');
     cy.contains('button', 'Edit').click();
     this.expectBreadcrumbText('Edit');
   }
@@ -68,12 +69,20 @@ export abstract class PageHelper {
     cy.get('.breadcrumb-item.active').should('have.text', text);
   }
 
+  getTabs() {
+    return cy.get('.nav.nav-tabs li');
+  }
+
+  getTab(tabName: string) {
+    return cy.contains('.nav.nav-tabs li', new RegExp(`^${tabName}$`));
+  }
+
   getTabText(index: number) {
-    return cy.get('.nav.nav-tabs li').its(index).text();
+    return this.getTabs().its(index).text();
   }
 
   getTabsCount(): any {
-    return cy.get('.nav.nav-tabs li').its('length');
+    return this.getTabs().its('length');
   }
 
   /**
@@ -118,42 +127,29 @@ export abstract class PageHelper {
     return cy.get('cd-table .dataTables_wrapper');
   }
 
-  getTableTotalCount() {
-    this.waitDataTableToLoad();
-
-    return cy.get('.datatable-footer-inner .page-count span').then(($elem) => {
-      const text = $elem
-        .filter((_i, e) => e.innerText.includes('total'))
-        .first()
-        .text();
-
-      return Number(text.match(/(\d+)\s+total/)[1]);
-    });
+  private getTableCountSpan(spanType: 'selected' | 'found' | 'total') {
+    return cy.contains('.datatable-footer-inner .page-count span', spanType);
   }
 
-  getTableSelectedCount() {
+  // Get 'selected', 'found', or 'total' row count of a table.
+  getTableCount(spanType: 'selected' | 'found' | 'total') {
     this.waitDataTableToLoad();
-
-    return cy.get('.datatable-footer-inner .page-count span').then(($elem) => {
+    return this.getTableCountSpan(spanType).then(($elem) => {
       const text = $elem
-        .filter((_i, e) => e.innerText.includes('selected'))
+        .filter((_i, e) => e.innerText.includes(spanType))
         .first()
         .text();
 
-      return Number(text.match(/(\d+)\s+selected/)[1]);
+      return Number(text.match(/(\d+)\s+\w*/)[1]);
     });
   }
 
-  getTableFoundCount() {
+  // Wait until selected', 'found', or 'total' row count of a table equal to a number.
+  expectTableCount(spanType: 'selected' | 'found' | 'total', count: number) {
     this.waitDataTableToLoad();
-
-    return cy.get('.datatable-footer-inner .page-count span').then(($elem) => {
-      const text = $elem
-        .filter((_i, e) => e.innerText.includes('found'))
-        .first()
-        .text();
-
-      return Number(text.match(/(\d+)\s+found/)[1]);
+    this.getTableCountSpan(spanType).should(($elem) => {
+      const text = $elem.first().text();
+      expect(Number(text.match(/(\d+)\s+\w*/)[1])).to.equal(count);
     });
   }
 
@@ -185,6 +181,15 @@ export abstract class PageHelper {
     }
   }
 
+  getTableCell(columnIndex: number, exactContent: string) {
+    this.waitDataTableToLoad();
+    this.seachTable(exactContent);
+    return cy.contains(
+      `datatable-body-row datatable-body-cell:nth-child(${columnIndex})`,
+      new RegExp(`^${exactContent}$`)
+    );
+  }
+
   getExpandCollapseElement(content?: string) {
     this.waitDataTableToLoad();
 
@@ -194,6 +199,7 @@ export abstract class PageHelper {
       return cy.get('.tc_expand-collapse').first();
     }
   }
+
   /**
    * Gets column headers of table
    */
@@ -220,10 +226,14 @@ export abstract class PageHelper {
     cy.contains(`.tc_filter_option .dropdown-item`, option).click();
   }
 
+  setPageSize(size: string) {
+    cy.get('cd-table .dataTables_paginate input').first().clear().type(size);
+  }
+
   seachTable(text: string) {
     this.waitDataTableToLoad();
 
-    cy.get('cd-table .dataTables_paginate input').first().clear().type('10');
+    this.setPageSize('10');
     cy.get('cd-table .search input').first().clear().type(text);
   }
 
@@ -233,27 +243,37 @@ export abstract class PageHelper {
     return cy.get('cd-table .search button').click();
   }
 
+  // Click the action button
+  clickActionButton(action: string) {
+    cy.get('.table-actions button.dropdown-toggle').first().click(); // open submenu
+    cy.get(`.table-actions li.${action}`).click(); // click on "action" menu item
+  }
+
   /**
    * This is a generic method to delete table rows.
    * It will select the first row that contains the provided name and delete it.
    * After that it will wait until the row is no longer displayed.
+   * @param name The string to search in table cells.
+   * @param columnIndex If provided, search string in columnIndex column.
    */
-  delete(name: string) {
+  delete(name: string, columnIndex?: number) {
     // Selects row
-    this.getFirstTableCell(name).click();
+    const getRow = columnIndex
+      ? this.getTableCell.bind(this, columnIndex)
+      : this.getFirstTableCell.bind(this);
+    getRow(name).click();
 
     // Clicks on table Delete button
-    cy.get('.table-actions button.dropdown-toggle').first().click(); // open submenu
-    cy.get('li.delete a').click(); // click on "delete" menu item
+    this.clickActionButton('delete');
 
     // Confirms deletion
-    cy.get('.custom-control-label').click();
-    cy.contains('button', 'Delete').click();
+    cy.get('cd-modal .custom-control-label').click();
+    cy.contains('cd-modal button', 'Delete').click();
 
     // Wait for modal to close
     cy.get('cd-modal').should('not.exist');
 
     // Waits for item to be removed from table
-    this.getFirstTableCell(name).should('not.exist');
+    getRow(name).should('not.exist');
   }
 }
index 2d604db34394e5dad389212502ff8817fe20b06c..9cb84480b6432cdda5f48dedf6020d18b0d662e3 100644 (file)
@@ -111,7 +111,7 @@ describe('Dashboard Main Page', () => {
         }
 
         spec.pageObject.navigateTo();
-        spec.pageObject.getTableTotalCount().then((tableCount) => {
+        spec.pageObject.getTableCount('total').then((tableCount) => {
           expect(tableCount).to.eq(
             dashCount,
             `Text of card "${spec.cardName}" and regex "${spec.regexMatcher}" resulted in ${dashCount} ` +