diff --git a/.github/workflows/deploy-docs.yml b/.github/workflows/deploy-docs.yml index 6ddf449..a376eba 100644 --- a/.github/workflows/deploy-docs.yml +++ b/.github/workflows/deploy-docs.yml @@ -31,10 +31,12 @@ jobs: echo "prod=true" >> "$GITHUB_OUTPUT" echo "base_url=docs" >> "$GITHUB_OUTPUT" echo "project=marketdata-docs" >> "$GITHUB_OUTPUT" + echo "environment=production" >> "$GITHUB_OUTPUT" else echo "prod=" >> "$GITHUB_OUTPUT" echo "base_url=docs-staging" >> "$GITHUB_OUTPUT" echo "project=marketdata-docs-staging" >> "$GITHUB_OUTPUT" + echo "environment=staging" >> "$GITHUB_OUTPUT" fi - name: Build @@ -67,6 +69,31 @@ jobs: " Cache-Control: public, max-age=31536000, immutable" \ > build/_headers + - name: Upload to R2 + env: + AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }} + AWS_ENDPOINT_URL: https://${{ secrets.CLOUDFLARE_ACCOUNT_ID }}.r2.cloudflarestorage.com + AWS_REGION: auto + run: | + aws s3 sync build/ \ + "s3://www-marketdata-app-builds/${{ steps.env.outputs.environment }}/" \ + --delete + + # - name: Notify orchestrator + # if: success() + # uses: peter-evans/repository-dispatch@v3 + # with: + # token: ${{ secrets.ORCHESTRATOR_PAT }} + # repository: MarketDataApp/www-marketdata-app + # event-type: site-built + # client-payload: >- + # { + # "source": "docs", + # "environment": "${{ steps.env.outputs.environment }}", + # "commit_sha": "${{ github.sha }}" + # } + - name: Deploy to Cloudflare Pages uses: cloudflare/wrangler-action@v3 with: @@ -101,3 +128,34 @@ jobs: accountId: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }} command: deploy workingDirectory: worker + + post-deploy-tests: + needs: deploy + if: github.ref == 'refs/heads/main' + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: yarn + + - run: yarn install --frozen-lockfile + + - name: Install worker test dependencies + working-directory: worker + run: yarn install --frozen-lockfile + + - name: Run integration tests against production + working-directory: worker + env: + TEST_ENV: production + run: yarn test:integration + + - run: npx playwright install chromium + + - name: Run e2e tests against production + env: + TEST_ENV: production + run: yarn test:e2e diff --git a/.github/workflows/pr-checks.yml b/.github/workflows/pr-checks.yml new file mode 100644 index 0000000..0555920 --- /dev/null +++ b/.github/workflows/pr-checks.yml @@ -0,0 +1,62 @@ +name: PR Checks (Staging Integration Tests) + +on: + pull_request: + branches: [main] + +jobs: + worker-unit-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: yarn + cache-dependency-path: worker/yarn.lock + + - run: yarn install --frozen-lockfile + working-directory: worker + + - run: yarn test + working-directory: worker + + staging-integration-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: yarn + cache-dependency-path: worker/yarn.lock + + - run: yarn install --frozen-lockfile + working-directory: worker + + - name: Run integration tests against staging + working-directory: worker + env: + TEST_ENV: staging + run: yarn test:integration + + staging-e2e-tests: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v4 + + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: yarn + + - run: yarn install --frozen-lockfile + + - run: npx playwright install chromium + + - name: Run e2e tests against staging + env: + TEST_ENV: staging + run: yarn test:e2e diff --git a/.gitignore b/.gitignore index 9597e70..0512b4f 100644 --- a/.gitignore +++ b/.gitignore @@ -18,6 +18,7 @@ llm-docs/ *.draft .cursorignore .playwright-mcp/ +test-results/ npm-debug.log* yarn-debug.log* diff --git a/docusaurus.config.js b/docusaurus.config.js index b7efe09..5ef2eba 100644 --- a/docusaurus.config.js +++ b/docusaurus.config.js @@ -80,6 +80,34 @@ const config = { from: "/api/universal-parameters/feed", to: "/api/universal-parameters/mode", }, + { + from: "/sheets/automatic-refreshing", + to: "/sheets/automatic-refresh", + }, + { + from: "/sheets/stockdata", + to: "/sheets/stocks/stockdata", + }, + { + from: "/sheets/earnings", + to: "/sheets/stocks/earnings", + }, + { + from: "/sheets/optiondata", + to: "/sheets/options/optiondata", + }, + { + from: "/sheets/optionlookup", + to: "/sheets/options/optionlookup", + }, + { + from: "/sheets/optionchain", + to: "/sheets/options/optionchain", + }, + { + from: "/sheets/marketstatus", + to: "/sheets/markets/marketstatus", + }, ], }, ], diff --git a/e2e/context7-widget.spec.js b/e2e/context7-widget.spec.js new file mode 100644 index 0000000..c854de7 --- /dev/null +++ b/e2e/context7-widget.spec.js @@ -0,0 +1,45 @@ +/** + * Smoke test: verifies the Context7 chat widget loads and renders + * the expected DOM structure on pages where it should appear. + * + * Catches breaking changes from Context7 (renamed classes, removed + * shadow DOM, changed container IDs, script load failures, etc.). + * + * Run with: yarn test:e2e + */ +import { test, expect } from '@playwright/test'; + +const BASE_URL = process.env.TEST_ENV === 'staging' + ? 'https://www.marketdata.app/docs-staging' + : 'https://www.marketdata.app/docs'; + +// One representative page per widget configuration. +const WIDGET_PAGES = [ + { path: '/api', name: 'API' }, + { path: '/sheets', name: 'Sheets' }, + { path: '/sdk/py', name: 'Python SDK' }, + { path: '/sdk/go', name: 'Go SDK' }, + { path: '/sdk/php', name: 'PHP SDK' }, +]; + +for (const { path, name } of WIDGET_PAGES) { + test(`Context7 widget loads on ${name} page (${path})`, async ({ page }) => { + await page.goto(`${BASE_URL}${path}`, { waitUntil: 'domcontentloaded' }); + + // 1. Widget script tag should be injected. + const script = page.locator('#context7-widget-script'); + await expect(script).toBeAttached({ timeout: 10_000 }); + + // 2. Widget container should appear. + const container = page.locator('#context7-widget'); + await expect(container).toBeAttached({ timeout: 15_000 }); + + // 3. Shadow DOM should be created with the expected panel structure. + const hasPanel = await container.evaluate((el) => { + const sr = el.shadowRoot; + if (!sr) return false; + return sr.querySelector('.c7-panel') !== null; + }); + expect(hasPanel, 'Shadow DOM missing .c7-panel element').toBe(true); + }); +} diff --git a/package.json b/package.json index 015ea4d..ba603e8 100644 --- a/package.json +++ b/package.json @@ -13,7 +13,8 @@ "write-translations": "docusaurus write-translations", "write-heading-ids": "docusaurus write-heading-ids", "typecheck": "tsc", - "generate-docs": "node scripts/generate-master-docs.js" + "generate-docs": "node scripts/generate-master-docs.js", + "test:e2e": "playwright test" }, "dependencies": { "@docusaurus/core": "3.0.1", @@ -30,6 +31,7 @@ "devDependencies": { "@docusaurus/module-type-aliases": "3.0.1", "@docusaurus/tsconfig": "3.0.1", + "@playwright/test": "^1.58.2", "@tsconfig/docusaurus": "^1.0.5", "typescript": "^4.7.4" }, diff --git a/playwright.config.js b/playwright.config.js new file mode 100644 index 0000000..b8b784d --- /dev/null +++ b/playwright.config.js @@ -0,0 +1,13 @@ +import { defineConfig } from '@playwright/test'; + +export default defineConfig({ + testDir: './e2e', + timeout: 30_000, + retries: 1, + use: { + headless: true, + }, + projects: [ + { name: 'chromium', use: { browserName: 'chromium' } }, + ], +}); diff --git a/worker/handler.integration.test.js b/worker/handler.integration.test.js index 9c4f1d9..873f83b 100644 --- a/worker/handler.integration.test.js +++ b/worker/handler.integration.test.js @@ -2,77 +2,104 @@ * Integration test: fetches the live sitemap and verifies every doc URL * returns valid markdown via Accept: text/markdown header. * + * Set TEST_ENV=staging or TEST_ENV=production to test a single environment. + * When unset, tests both. + * + * Note: The production sitemap is always used as the URL source because + * staging has noIndex enabled which suppresses sitemap generation. URLs + * are rewritten to the target environment's base path. + * * Run with: yarn test:integration * Requires network access to www.marketdata.app */ import { describe, it, expect } from 'vitest'; -const SITEMAP_URL = 'https://www.marketdata.app/docs/sitemap.xml'; +const PROD_SITEMAP_URL = 'https://www.marketdata.app/docs/sitemap.xml'; +const PROD_BASE = '/docs'; + +const ENVIRONMENTS = { + staging: { basePath: '/docs-staging' }, + production: { basePath: '/docs' }, +}; + +const TEST_ENV = process.env.TEST_ENV; +const envs = TEST_ENV ? { [TEST_ENV]: ENVIRONMENTS[TEST_ENV] } : ENVIRONMENTS; // Pages generated by Docusaurus that have no markdown source -const SKIP_PATTERNS = [ - '/docs/search', - '/docs/docs/', // root landing page (auto-generated) +const SKIP_SUFFIX_PATTERNS = [ + '/search', + '/docs/', // root landing page (auto-generated) +]; + +const SKIP_CONTAINS = [ '/tags', // tag listing pages ]; // Pages with no markdown source — auto-generated or mapped to unexpected paths // See: https://github.com/MarketDataApp/documentation/issues/127 -const SKIP_EXACT = [ - '/docs/', // root page source is at docs/index.md, outside doc plugin paths - '/docs/sdk/stocks', // broken category URLs (should be /sdk/go/stocks, etc.) - '/docs/sdk/markets', - '/docs/sdk/options', +const SKIP_SUFFIX_EXACT = [ + '/', // root page source is at docs/index.md, outside doc plugin paths + '/sdk/stocks', // broken category URLs (should be /sdk/go/stocks, etc.) + '/sdk/markets', + '/sdk/options', ]; -function shouldSkip(url) { - const path = new URL(url).pathname; - if (SKIP_EXACT.includes(path)) return true; - return SKIP_PATTERNS.some((pattern) => url.includes(pattern)); +function shouldSkip(path, basePath) { + if (SKIP_SUFFIX_EXACT.some((suffix) => path === `${basePath}${suffix}`)) return true; + if (SKIP_SUFFIX_PATTERNS.some((suffix) => path.startsWith(`${basePath}${suffix}`))) return true; + return SKIP_CONTAINS.some((pattern) => path.includes(pattern)); } async function fetchSitemapUrls() { - const res = await fetch(SITEMAP_URL); + const res = await fetch(PROD_SITEMAP_URL); const xml = await res.text(); const urls = []; for (const match of xml.matchAll(/([^<]+)<\/loc>/g)) { - urls.push(match[1]); + urls.push(new URL(match[1]).pathname); } return urls; } -describe('markdown serving (live sitemap)', async () => { - const allUrls = await fetchSitemapUrls(); - const docUrls = allUrls.filter((url) => !shouldSkip(url)); +/** Rewrite a production path to the target environment's base path. */ +function rebasePath(prodPath, targetBasePath) { + return prodPath.replace(PROD_BASE, targetBasePath); +} - it(`sitemap has URLs to test`, () => { - expect(docUrls.length).toBeGreaterThan(50); - }); +for (const [env, { basePath }] of Object.entries(envs)) { + describe(`markdown serving — ${env} (live sitemap)`, async () => { + const prodPaths = await fetchSitemapUrls(); + const targetPaths = prodPaths + .map((p) => rebasePath(p, basePath)) + .filter((p) => !shouldSkip(p, basePath)); - for (const url of docUrls) { - const path = new URL(url).pathname; + it(`sitemap has URLs to test`, () => { + expect(targetPaths.length).toBeGreaterThan(50); + }); - it(`${path} returns markdown`, async () => { - const res = await fetch(url, { - headers: { Accept: 'text/markdown' }, - redirect: 'follow', - }); + for (const path of targetPaths) { + it(`${path} returns markdown`, async () => { + const url = `https://www.marketdata.app${path}`; + const res = await fetch(url, { + headers: { Accept: 'text/markdown' }, + redirect: 'follow', + }); - expect(res.status, `${path} returned ${res.status}`).toBe(200); - expect(res.headers.get('content-type')).toContain('text/markdown'); + expect(res.status, `${path} returned ${res.status}`).toBe(200); + expect(res.headers.get('content-type')).toContain('text/markdown'); - const text = await res.text(); - expect(text.length, `${path} returned empty body`).toBeGreaterThan(0); + const text = await res.text(); + expect(text.length, `${path} returned empty body`).toBeGreaterThan(0); - // Should not contain raw frontmatter - expect(text.startsWith('---\n'), `${path} still has frontmatter`).toBe(false); + // Should not contain raw frontmatter + expect(text.startsWith('---\n'), `${path} still has frontmatter`).toBe(false); - // Should not contain MDX import statements - expect(text, `${path} still has import statements`).not.toMatch(/^import\s+\w+\s+from\s+"/m); + // Should not contain MDX import statements + expect(text, `${path} still has import statements`).not.toMatch(/^import\s+\w+\s+from\s+"/m); - // Should not contain raw JSX tags from Docusaurus components - expect(text, `${path} still has `).not.toContain(''); - expect(text, `${path} still has `).not.toContain(''); + expect(text, `${path} still has { + it('found redirects to test', () => { + expect(redirects.length).toBeGreaterThan(0); + }); + + for (const { from, to } of redirects) { + it(`${from} → ${to}`, async () => { + const fromRes = await fetch(`${baseUrl}${from}`); + expect(fromRes.status, `${from} returned ${fromRes.status}`).toBe(200); + + const html = await fromRes.text(); + // Docusaurus client-redirects generates pages with meta refresh / JS redirect + expect(html, `${from} page does not contain redirect to ${to}`).toContain(to); + + // Verify destination actually exists + const toRes = await fetch(`${baseUrl}${to}`); + expect(toRes.status, `destination ${to} returned ${toRes.status}`).toBe(200); + }); + } + }); +} diff --git a/yarn.lock b/yarn.lock index 4d23878..f8cc670 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1638,6 +1638,13 @@ "@nodelib/fs.scandir" "2.1.5" fastq "^1.6.0" +"@playwright/test@^1.58.2": + version "1.58.2" + resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.58.2.tgz#b0ad585d2e950d690ef52424967a42f40c6d2cbd" + integrity sha512-akea+6bHYBBfA9uQqSYmlJXn61cTa+jbO87xVLCWbTqbWadRVmhxlXATaOjOgcBaWU4ePo0wB41KMFv3o35IXA== + dependencies: + playwright "1.58.2" + "@pnpm/config.env-replace@^1.1.0": version "1.1.0" resolved "https://registry.npmjs.org/@pnpm/config.env-replace/-/config.env-replace-1.1.0.tgz" @@ -4094,7 +4101,7 @@ fs.realpath@^1.0.0: resolved "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz" integrity sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw== -fsevents@~2.3.2: +fsevents@2.3.2, fsevents@~2.3.2: version "2.3.2" resolved "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz" integrity sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA== @@ -6349,6 +6356,20 @@ pkg-up@^3.1.0: dependencies: find-up "^3.0.0" +playwright-core@1.58.2: + version "1.58.2" + resolved "https://registry.yarnpkg.com/playwright-core/-/playwright-core-1.58.2.tgz#ac5f5b4b10d29bcf934415f0b8d133b34b0dcb13" + integrity sha512-yZkEtftgwS8CsfYo7nm0KE8jsvm6i/PTgVtB8DL726wNf6H2IMsDuxCpJj59KDaxCtSnrWan2AeDqM7JBaultg== + +playwright@1.58.2: + version "1.58.2" + resolved "https://registry.yarnpkg.com/playwright/-/playwright-1.58.2.tgz#afe547164539b0bcfcb79957394a7a3fa8683cfd" + integrity sha512-vA30H8Nvkq/cPBnNw4Q8TWz1EJyqgpuinBcHET0YVJVFldr8JDNiU9LaWAE1KqSkRYazuaBhTpB5ZzShOezQ6A== + dependencies: + playwright-core "1.58.2" + optionalDependencies: + fsevents "2.3.2" + postcss-calc@^8.2.3: version "8.2.4" resolved "https://registry.npmjs.org/postcss-calc/-/postcss-calc-8.2.4.tgz"