From fe1b40b99d19a1fa7511adebceb0720280eb4e04 Mon Sep 17 00:00:00 2001 From: Taras Kushnir Date: Fri, 10 Oct 2025 16:04:49 +0300 Subject: [PATCH] Add widget test Mainly for bug fixed in 5df983a5c43536e05f6cc1da2e344c05d64dba7f --- .github/workflows/{eslint.yml => widget.yml} | 10 +- .gitignore | 2 + Makefile | 3 + README.md | 2 +- widget/esbuild.test.config.mjs | 31 ++++ widget/js/html.js | 2 +- widget/js/progress.js | 2 +- widget/js/widget.js | 9 +- widget/package-lock.json | 157 ++++++++++++++++++- widget/package.json | 5 +- widget/test/widget.test.js | 93 +++++++++++ 11 files changed, 299 insertions(+), 17 deletions(-) rename .github/workflows/{eslint.yml => widget.yml} (85%) create mode 100644 widget/esbuild.test.config.mjs create mode 100644 widget/test/widget.test.js diff --git a/.github/workflows/eslint.yml b/.github/workflows/widget.yml similarity index 85% rename from .github/workflows/eslint.yml rename to .github/workflows/widget.yml index cf4ac362..77abafc2 100644 --- a/.github/workflows/eslint.yml +++ b/.github/workflows/widget.yml @@ -1,10 +1,11 @@ -name: eslint +name: widget on: push: branches: - '**' paths: - 'widget/js/**' + - 'widget/test/**' - '.github/workflows/eslint.yml' # - 'web/js/**' - '!node_modules/**' @@ -23,8 +24,7 @@ permissions: # pull-requests: read jobs: - golangci: - name: lint + lint-and-test: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 @@ -40,3 +40,7 @@ jobs: - name: Run eslint for widget run: npm run lint working-directory: ./widget + + - name: Run widget tests + run: npm run test + working-directory: ./widget diff --git a/.gitignore b/.gitignore index 1abd4dfc..03d0a039 100644 --- a/.gitignore +++ b/.gitignore @@ -27,6 +27,8 @@ web/static/* widget/static/ widget/lib/*.js widget/lib/*.js.map +widget/test/bundle.test.js +widget/test/bundle.test.js.map pkg/db/migrations/postgres/000000_sqlc_fix.sql pc.env diff --git a/Makefile b/Makefile index 61e47a4b..5040656a 100644 --- a/Makefile +++ b/Makefile @@ -12,6 +12,9 @@ test-unit: test-unit-cover: @env GOFLAGS="-mod=vendor" CGO_ENABLED=0 go test -short -coverprofile=coverage_unit.cov -coverpkg=$(shell go list ./... | paste -sd, -) ./... +test-widget-unit: + cd widget && env STAGE="$(STAGE)" npm run test + bench-unit: env GOFLAGS="-mod=vendor" CGO_ENABLED=0 go test -bench=. -benchtime=20s -short ./... diff --git a/README.md b/README.md index a02b30fc..aaa6280c 100644 --- a/README.md +++ b/README.md @@ -7,7 +7,7 @@ ![GitHub go.mod Go version](https://img.shields.io/github/go-mod/go-version/PrivateCaptcha/PrivateCaptcha) -![CI](https://github.com/PrivateCaptcha/PrivateCaptcha/actions/workflows/ci.yaml/badge.svg) ![Go lint](https://github.com/PrivateCaptcha/PrivateCaptcha/actions/workflows/golangci-lint.yml/badge.svg) ![JS lint](https://github.com/PrivateCaptcha/PrivateCaptcha/actions/workflows/eslint.yml/badge.svg) +![CI](https://github.com/PrivateCaptcha/PrivateCaptcha/actions/workflows/ci.yaml/badge.svg) ![Go lint](https://github.com/PrivateCaptcha/PrivateCaptcha/actions/workflows/golangci-lint.yml/badge.svg) ![JS lint](https://github.com/PrivateCaptcha/PrivateCaptcha/actions/workflows/widget.yml/badge.svg) [![Maintainability Rating](https://sonarcloud.io/api/project_badges/measure?project=PrivateCaptcha_PrivateCaptcha&metric=sqale_rating)](https://sonarcloud.io/dashboard?id=PrivateCaptcha_PrivateCaptcha) [![Reliability Rating](https://sonarcloud.io/api/project_badges/measure?project=PrivateCaptcha_PrivateCaptcha&metric=reliability_rating)](https://sonarcloud.io/dashboard?id=PrivateCaptcha_PrivateCaptcha) diff --git a/widget/esbuild.test.config.mjs b/widget/esbuild.test.config.mjs new file mode 100644 index 00000000..7564ed34 --- /dev/null +++ b/widget/esbuild.test.config.mjs @@ -0,0 +1,31 @@ +import { build, transform } from 'esbuild'; +import { readFile } from "fs/promises" +import inlineWorkerPlugin from 'esbuild-plugin-inline-worker'; + +let CSSMinifyPlugin = { + name: "CSSMinifyPlugin", + setup(build) { + build.onLoad({ filter: /\.css$/ }, async (args) => { + const f = await readFile(args.path) + const css = await transform(f, { loader: "css", minify: false }) + return { loader: "text", contents: css.code } + }) + } +} + +build({ + entryPoints: ['./test/widget.test.js'], + bundle: true, + outfile: './test/bundle.test.js', + format: 'esm', + loader: { '.css': 'text' }, + external: ['node:test', 'node:assert', 'happy-dom'], + plugins: [ + CSSMinifyPlugin, + inlineWorkerPlugin({ + minify: false + }), + ], + minify: false, + sourcemap: true, +}).catch(() => process.exit(1)); diff --git a/widget/js/html.js b/widget/js/html.js index 8089eceb..2d6ceffa 100644 --- a/widget/js/html.js +++ b/widget/js/html.js @@ -1,7 +1,7 @@ 'use strict'; import { ProgressRing } from './progress.js'; -import { SafeHTMLElement } from "./utils"; +import { SafeHTMLElement } from "./utils.js"; import styles from "./styles.css" with { type: 'css' }; import * as i18n from './strings.js'; import * as errors from './errors.js'; diff --git a/widget/js/progress.js b/widget/js/progress.js index 7efba924..58c99b7f 100644 --- a/widget/js/progress.js +++ b/widget/js/progress.js @@ -1,6 +1,6 @@ 'use strict'; -import { SafeHTMLElement } from "./utils"; +import { SafeHTMLElement } from "./utils.js"; export class ProgressRing extends SafeHTMLElement { constructor() { diff --git a/widget/js/widget.js b/widget/js/widget.js index 4adb222a..4819095f 100644 --- a/widget/js/widget.js +++ b/widget/js/widget.js @@ -90,12 +90,17 @@ export class CaptchaWidget { defaultField = "g-recaptcha-response"; } + let sitekey = ""; + if (options.hasOwnProperty('sitekey')) { + sitekey = options.sitekey; + } + this._options = Object.assign({ startMode: this._element.dataset["startMode"] || "auto", debug: this._element.dataset["debug"], fieldName: this._element.dataset["solutionField"] || defaultField, puzzleEndpoint: this._element.dataset["puzzleEndpoint"] || defaultEndpoint, - sitekey: this._element.dataset["sitekey"] || "", + sitekey: sitekey || this._element.dataset["sitekey"] || "", displayMode: this._element.dataset["displayMode"] || "widget", lang: this._element.dataset["lang"] || "en", theme: this._element.dataset["theme"] || "light", @@ -131,7 +136,7 @@ export class CaptchaWidget { try { this.setState(STATE_LOADING); - this.trace('fetching puzzle'); + this.trace(`fetching puzzle. sitekey=${sitekey}`); const puzzleData = await getPuzzle(this._options.puzzleEndpoint, sitekey); this._puzzle = new Puzzle(puzzleData); if (this._puzzle && this._puzzle.isZero()) { this._errorCode = errors.ERROR_ZERO_PUZZLE; } diff --git a/widget/package-lock.json b/widget/package-lock.json index a205864b..cafe549b 100644 --- a/widget/package-lock.json +++ b/widget/package-lock.json @@ -15,7 +15,8 @@ "devDependencies": { "esbuild": "^0.25.0", "esbuild-plugin-inline-worker": "^0.1.1", - "eslint": "^9.23.0" + "eslint": "^9.23.0", + "happy-dom": "^12.0.0" } }, "node_modules/@esbuild/aix-ppc64": { @@ -730,9 +731,9 @@ "integrity": "sha512-QXUSXI3QVc/gJME0dBpXrag1kbzOqCjCX8/b54ntNyW6sjtoqxqRk3LTmXzaJoh71zMsDCjM+47jS7XiwN/+fQ==" }, "node_modules/brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, "dependencies": { "balanced-match": "^1.0.0", @@ -808,6 +809,12 @@ "node": ">= 8" } }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true + }, "node_modules/debug": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", @@ -831,6 +838,18 @@ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true }, + "node_modules/entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true, + "engines": { + "node": ">=0.12" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/esbuild": { "version": "0.25.10", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.10.tgz", @@ -1205,6 +1224,20 @@ "url": "https://github.com/sponsors/sindresorhus" } }, + "node_modules/happy-dom": { + "version": "12.10.3", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-12.10.3.tgz", + "integrity": "sha512-JzUXOh0wdNGY54oKng5hliuBkq/+aT1V3YpTM+lrN/GoLQTANZsMaIvmHiHe612rauHvPJnDZkZ+5GZR++1Abg==", + "dev": true, + "dependencies": { + "css.escape": "^1.5.1", + "entities": "^4.5.0", + "iconv-lite": "^0.6.3", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^2.0.0", + "whatwg-mimetype": "^3.0.0" + } + }, "node_modules/has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", @@ -1214,6 +1247,18 @@ "node": ">=8" } }, + "node_modules/iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "dependencies": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + }, + "engines": { + "node": ">=0.10.0" + } + }, "node_modules/ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -1511,6 +1556,12 @@ "node": ">=4" } }, + "node_modules/safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true + }, "node_modules/semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -1586,6 +1637,36 @@ "punycode": "^2.1.0" } }, + "node_modules/webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true, + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-encoding": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", + "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==", + "dev": true, + "dependencies": { + "iconv-lite": "0.6.3" + }, + "engines": { + "node": ">=12" + } + }, + "node_modules/whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true, + "engines": { + "node": ">=12" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -2013,9 +2094,9 @@ "integrity": "sha512-QXUSXI3QVc/gJME0dBpXrag1kbzOqCjCX8/b54ntNyW6sjtoqxqRk3LTmXzaJoh71zMsDCjM+47jS7XiwN/+fQ==" }, "brace-expansion": { - "version": "1.1.11", - "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.11.tgz", - "integrity": "sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==", + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.12.tgz", + "integrity": "sha512-9T9UjW3r0UW5c1Q7GTwllptXwhvYmEzFhzMfZ9H7FQWt+uZePjZPjBP/W1ZEyZ1twGWom5/56TF4lPcqjnDHcg==", "dev": true, "requires": { "balanced-match": "^1.0.0", @@ -2076,6 +2157,12 @@ "which": "^2.0.1" } }, + "css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true + }, "debug": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", @@ -2091,6 +2178,12 @@ "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", "dev": true }, + "entities": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-4.5.0.tgz", + "integrity": "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==", + "dev": true + }, "esbuild": { "version": "0.25.10", "resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.25.10.tgz", @@ -2359,12 +2452,35 @@ "integrity": "sha512-oahGvuMGQlPw/ivIYBjVSrWAfWLBeku5tpPE2fOPLi+WHffIWbuh2tCjhyQhTBPMf5E9jDEH4FOmTYgYwbKwtQ==", "dev": true }, + "happy-dom": { + "version": "12.10.3", + "resolved": "https://registry.npmjs.org/happy-dom/-/happy-dom-12.10.3.tgz", + "integrity": "sha512-JzUXOh0wdNGY54oKng5hliuBkq/+aT1V3YpTM+lrN/GoLQTANZsMaIvmHiHe612rauHvPJnDZkZ+5GZR++1Abg==", + "dev": true, + "requires": { + "css.escape": "^1.5.1", + "entities": "^4.5.0", + "iconv-lite": "^0.6.3", + "webidl-conversions": "^7.0.0", + "whatwg-encoding": "^2.0.0", + "whatwg-mimetype": "^3.0.0" + } + }, "has-flag": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", "dev": true }, + "iconv-lite": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz", + "integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==", + "dev": true, + "requires": { + "safer-buffer": ">= 2.1.2 < 3.0.0" + } + }, "ignore": { "version": "5.3.2", "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", @@ -2590,6 +2706,12 @@ "integrity": "sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==", "dev": true }, + "safer-buffer": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/safer-buffer/-/safer-buffer-2.1.2.tgz", + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==", + "dev": true + }, "semver": { "version": "6.3.1", "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", @@ -2644,6 +2766,27 @@ "punycode": "^2.1.0" } }, + "webidl-conversions": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-7.0.0.tgz", + "integrity": "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g==", + "dev": true + }, + "whatwg-encoding": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/whatwg-encoding/-/whatwg-encoding-2.0.0.tgz", + "integrity": "sha512-p41ogyeMUrw3jWclHWTQg1k05DSVXPLcVxRTYsXUk+ZooOCZLcoYgPZ/HL/D/N+uQPOtcp1me1WhBEaX02mhWg==", + "dev": true, + "requires": { + "iconv-lite": "0.6.3" + } + }, + "whatwg-mimetype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-3.0.0.tgz", + "integrity": "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q==", + "dev": true + }, "which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", diff --git a/widget/package.json b/widget/package.json index 65bae65e..574c2aa4 100644 --- a/widget/package.json +++ b/widget/package.json @@ -6,11 +6,12 @@ "devDependencies": { "esbuild": "^0.25.0", "esbuild-plugin-inline-worker": "^0.1.1", - "eslint": "^9.23.0" + "eslint": "^9.23.0", + "happy-dom": "^12.0.0" }, "scripts": { "build": "node esbuild.config.mjs", - "test": "echo \"Error: no test specified\" && exit 1", + "test": "node esbuild.test.config.mjs && node --test test/bundle.test.js", "lint": "eslint js/**/*.js" }, "dependencies": { diff --git a/widget/test/widget.test.js b/widget/test/widget.test.js new file mode 100644 index 00000000..4a5236ce --- /dev/null +++ b/widget/test/widget.test.js @@ -0,0 +1,93 @@ +import test from 'node:test'; +import assert from 'node:assert'; +import { Window } from 'happy-dom'; + +const window = new Window({ + url: 'https://localhost:8080' +}); + +global.window = window; +global.document = window.document; +global.HTMLElement = window.HTMLElement; +global.CustomEvent = window.CustomEvent; +global.CSSStyleSheet = window.CSSStyleSheet; + +// we have to mock worker too +global.Worker = class Worker { + constructor() { + this.onmessage = null; + this.onerror = null; + } + + postMessage(data) { + setTimeout(() => { + if (data.command === 'init') { + this.onmessage?.({ data: { command: 'init' } }); + } else if (data.command === 'solve') { + // Simulate immediate solution for zero puzzle + this.onmessage?.({ + data: { + command: 'solve', + argument: { + id: BigInt(data.argument.id || 0), + solution: new Uint8Array(8), + wasm: false + } + } + }); + } + }, 10); + } + + terminate() { } +}; + + +test('CaptchaWidget execute() fires finished event and callback', async (t) => { + document.body.innerHTML = ` +
+
+
+
+ `; + + let callbackCalled = false; + global.window.testFinishedCallback = (widget) => { + callbackCalled = true; + }; + + const { CaptchaWidget } = await import('../js/widget.js'); + + const element = document.querySelector('.private-captcha'); + assert.ok(element, 'Should find captcha element'); + + const widget = new CaptchaWidget(element, { + sitekey: 'aaaaaaaabbbbccccddddeeeeeeeeeeee', + debug: true + }); + + // Set up event listener + let eventFired = false; + element.addEventListener('privatecaptcha:finish', (event) => { + eventFired = true; + assert.ok(event.detail.widget, 'Event should include widget in detail'); + assert.strictEqual(event.detail.element, element, 'Event should include element in detail'); + }); + + widget.execute(); + + // Wait for async operations to complete (assumes zero puzzle mock above) + await new Promise(resolve => setTimeout(resolve, 150)); + + assert.strictEqual(eventFired, true, 'privatecaptcha:finish event should be fired'); + assert.strictEqual(callbackCalled, true, 'Finished callback should be called'); + + // Verify solution is available + const solution = widget.solution(); + assert.ok(solution, 'Widget should have a solution'); + assert.ok(typeof solution === 'string', 'Solution should be a string'); + + console.log('✓ Widget execute test passed'); +});