diff --git a/.github/workflows/cleanup-report.yml b/.github/workflows/cleanup-report.yml new file mode 100644 index 0000000000..8f75035c3e --- /dev/null +++ b/.github/workflows/cleanup-report.yml @@ -0,0 +1,55 @@ +name: Delete Visual Tests Reports + +on: + delete: + branches-ignore: [master, main, gh-pages] + +concurrency: + group: ${{ github.event.ref }} + cancel-in-progress: true + +jobs: + delete_reports: + name: Delete Reports + runs-on: ubuntu-latest + env: + BRANCH_REPORTS_DIR: reports/${{ github.event.ref }} + steps: + - name: Checkout GitHub Pages Branch + uses: actions/checkout@v5 + with: + ref: gh-pages + + - name: Set Git User + run: | + git config --global user.name "github-actions[bot]" + git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Check for workflow reports + run: | + if [ -d "$BRANCH_REPORTS_DIR" ] && [ "$(ls -A $BRANCH_REPORTS_DIR)" ]; then + echo "BRANCH_REPORTS_EXIST=true" >> "$GITHUB_ENV" + else + echo "BRANCH_REPORTS_EXIST=false" >> "$GITHUB_ENV" + fi + + - name: Delete reports from repo for branch + if: ${{ env.BRANCH_REPORTS_EXIST == 'true' }} + timeout-minutes: 3 + run: | + cd "$(dirname "$BRANCH_REPORTS_DIR")" + rm -rf "${{ github.event.ref }}" + git add . + git commit -m "workflow: remove all reports for branch ${{ github.event.ref }}" + while true; do + git pull --rebase + if [ $? -ne 0 ]; then + echo "Failed to rebase. Please review manually." + exit 1 + fi + git push + if [ $? -eq 0 ]; then + echo "Successfully pushed HTML reports to repo." + exit 0 + fi + done \ No newline at end of file diff --git a/.github/workflows/screenshot-capture.yml b/.github/workflows/screenshot-capture.yml new file mode 100644 index 0000000000..9214b929fe --- /dev/null +++ b/.github/workflows/screenshot-capture.yml @@ -0,0 +1,125 @@ +name: Screenshot Capture + +on: + workflow_run: + workflows: ["Release to Production"] + types: [completed] + workflow_dispatch: + +env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + PALETTE_API_KEY: ${{ secrets.PALETTE_API_KEY }} + FULLSTORY_ORGID: ${{ secrets.FULLSTORY_ORGID }} + ALGOLIA_ADMIN_KEY: ${{ secrets.ALGOLIA_ADMIN_KEY }} + ALGOLIA_APP_ID: ${{ secrets.ALGOLIA_APP_ID }} + ALGOLIA_SEARCH_KEY: ${{ secrets.ALGOLIA_SEARCH_KEY }} + ALGOLIA_INDEX_NAME: ${{ secrets.ALGOLIA_INDEX_NAME }} + DISABLE_PACKS_INTEGRATIONS: ${{ secrets.DISABLE_PACKS_INTEGRATIONS }} + DISABLE_SECURITY_INTEGRATIONS: ${{ secrets.DISABLE_SECURITY_INTEGRATIONS }} + SHOW_LAST_UPDATE_TIME: ${{ secrets.SHOW_LAST_UPDATE_TIME }} + DSO_AUTH_TOKEN: ${{ secrets.DSO_AUTH_TOKEN }} + SEERS_CMP_KEY: ${{ secrets.SEERS_CMP_KEY }} + +jobs: + create-assets: + name: asset-builds + runs-on: ubuntu-latest + steps: + - name: Checkout Repository + uses: actions/checkout@v5 + + - name: Setup Node.js Environment + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: 'yarn' + + - name: Install Dependencies + run: yarn install --frozen-lockfile + + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium + + - name: Build Website + run: | + touch .env + yarn build + + - name: Upload Build + uses: actions/upload-artifact@v4 + with: + name: build + path: build/ + if-no-files-found: error + retention-days: 1 + + visual-snapshots: + runs-on: ubuntu-latest + needs: [create-assets] + strategy: + fail-fast: false + matrix: + shardIndex: [1, 2, 3, 4] + shardTotal: [4] + steps: + - name: Checkout Repository + uses: actions/checkout@v5 + + - name: Setup Node.js Environment + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: 'yarn' + + - name: Install Dependencies + uses: Wandalen/wretry.action@v3 + with: + command: yarn install --frozen-lockfile + attempt_limit: 3 + attempt_delay: 60000 + + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium + + - name: Download build artifact + uses: Wandalen/wretry.action@v3 + with: + attempt_limit: 3 + attempt_delay: 60000 + action: actions/download-artifact@v5 + with: | + name: build + path: build + + - name: Take Screenshots with Playwright + env: + PLAYWRIGHT_SNAPSHOT_DIR: ${{ github.workspace }}/screenshots + run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} --update-snapshots || exit 0 + + - name: Upload Screenshots + uses: actions/upload-artifact@v4 + with: + name: screenshots-${{ matrix.shardIndex }} + path: screenshots/ + if-no-files-found: error + retention-days: 3 + + merge-snapshots: + name: Merge Screenshots + runs-on: ubuntu-latest + needs: [visual-snapshots] + steps: + - name: Download screenshots + uses: actions/download-artifact@v5 + with: + path: screenshots + pattern: screenshots-* + merge-multiple: true + + - name: Upload merged screenshots + uses: actions/upload-artifact@v4 + with: + name: screenshots + path: screenshots/ + if-no-files-found: error + retention-days: 7 \ No newline at end of file diff --git a/.github/workflows/visual-comparison.yml b/.github/workflows/visual-comparison.yml new file mode 100644 index 0000000000..c4d519498d --- /dev/null +++ b/.github/workflows/visual-comparison.yml @@ -0,0 +1,235 @@ +name: Visual Comparison + +on: + pull_request: + types: [opened, reopened, synchronize, labeled] + branches-ignore: + - "version-*" + - "docs-rel-*" + +env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + HTML_REPORT_URL_PATH: reports/${{ github.head_ref }}/${{ github.run_id }}/${{ github.run_attempt }} + +concurrency: + group: visual-comparison-${{ github.workflow }}-${{ github.head_ref }} + cancel-in-progress: true + +jobs: + run-ci: + runs-on: ubuntu-latest + if: >- + ${{ + !github.event.pull_request.draft && + github.actor != 'dependabot[bot]' && + github.actor != 'dependabot-preview[bot]' && + contains(github.event.pull_request.labels.*.name, 'visual-tests') + }} + steps: + - name: PR metadata + run: | + echo "GITHUB_BASE_REF: ${{ github.base_ref }}" + echo "GITHUB_HEAD_REF: ${{ github.head_ref }}" + echo "GITHUB_REF_NAME: ${{ github.ref_name }}" + echo "HTML_REPORT_URL_PATH ${{ env.HTML_REPORT_URL_PATH }}" + + create-assets: + name: Create Build Assets + needs: run-ci + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v5 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: 'yarn' + + - name: Post PR comment + uses: mshick/add-pr-comment@v2 + with: + message: | + 🤖 Starting the visual tests. This will take approximately an hour. + refresh-message-position: true + + - name: Install dependencies + uses: Wandalen/wretry.action@v3 + with: + command: yarn install --frozen-lockfile + attempt_limit: 3 + attempt_delay: 60000 + + - name: Build site + run: | + touch .env + yarn build + + - name: Upload build artifact + uses: actions/upload-artifact@v4 + with: + name: build + path: build + if-no-files-found: error + retention-days: 1 + + take-screenshots: + name: Visual Comparison + needs: + - run-ci + - create-assets + runs-on: ubuntu-latest + strategy: + fail-fast: false + matrix: + shardIndex: [1, 2, 3, 4] + shardTotal: [4] + steps: + - name: Checkout repository + uses: actions/checkout@v5 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: 'yarn' + + - name: Install dependencies + uses: Wandalen/wretry.action@v3 + with: + command: yarn install --frozen-lockfile + attempt_limit: 3 + attempt_delay: 60000 + + - name: Install Playwright browsers + run: npx playwright install --with-deps chromium + + - name: Download build artifact + uses: Wandalen/wretry.action@v3 + with: + attempt_limit: 3 + attempt_delay: 60000 + action: actions/download-artifact@v5 + with: | + name: build + path: build + + - name: Download reference screenshots + run: ./static/scripts/screenshot-artifacts.sh ./screenshots + + - name: Take screenshots with Playwright + env: + PLAYWRIGHT_SNAPSHOT_DIR: ${{ github.workspace }}/screenshots + run: npx playwright test --shard=${{ matrix.shardIndex }}/${{ matrix.shardTotal }} || exit 0 + + - name: Upload blob report + uses: actions/upload-artifact@v4 + with: + name: blob-report-${{ matrix.shardIndex }} + path: blob-report + retention-days: 1 + + merge-reports: + name: Merge Reports + needs: take-screenshots + runs-on: ubuntu-latest + steps: + - name: Checkout repository + uses: actions/checkout@v5 + + - name: Setup Node.js + uses: actions/setup-node@v4 + with: + node-version: "20" + cache: 'yarn' + + - name: Install dependencies + uses: Wandalen/wretry.action@v3 + with: + command: yarn install --frozen-lockfile + attempt_limit: 3 + attempt_delay: 60000 + + - name: Download blob reports + uses: actions/download-artifact@v5 + with: + path: all-blob-reports + pattern: blob-report-* + merge-multiple: true + + - name: Merge into HTML report + run: npx playwright merge-reports --reporter html ./all-blob-reports + + - name: Upload report artifact + id: report + uses: actions/upload-artifact@v4 + with: + name: report + path: playwright-report + if-no-files-found: error + retention-days: 1 + + publish_report: + name: Publish HTML Report + needs: + - run-ci + - take-screenshots + - merge-reports + runs-on: ubuntu-latest + continue-on-error: true + steps: + - name: Checkout GitHub Pages branch + uses: actions/checkout@v5 + with: + ref: gh-pages + + - name: Set git user + run: | + git config --global user.name "github-actions[bot]" + git config --global user.email "41898282+github-actions[bot]@users.noreply.github.com" + + - name: Download HTML report + uses: actions/download-artifact@v5 + with: + name: report + path: ${{ env.HTML_REPORT_URL_PATH }} + + - name: Push HTML report + timeout-minutes: 5 + run: | + git add . + git commit -m "workflow: add HTML report for run-id ${{ github.run_id }} (attempt: ${{ github.run_attempt }})" + while true; do + git pull --rebase + if [ $? -ne 0 ]; then + echo "Failed to rebase. Please review manually." + exit 1 + fi + + git push + if [ $? -eq 0 ]; then + echo "Successfully pushed HTML report to repo." + exit 0 + fi + done + + - name: DNS wait + run: sleep 120 + + - name: Publish notice + run: | + echo "::notice title=Published Playwright Test Report::https://temporalio.github.io/documentation/${{ env.HTML_REPORT_URL_PATH }}" + + - name: Post report URL comment + uses: mshick/add-pr-comment@v2 + with: + message: | + 📋 Visual Report for branch ${{ github.head_ref }} with CI run ${{ github.run_id }} and attempt ${{ github.run_attempt }} is ready at + https://temporalio.github.io/documentation/${{ env.HTML_REPORT_URL_PATH }} + + 💡 You may have to wait for DNS to resolve or the GitHub Pages job to complete. You can view the progress of the GitHub Pages job [here](https://github.com/temporalio/documentation/actions/workflows/pages/pages-build-deployment). + message-failure: | + 👎 Uh oh! Unable to publish Visual Report URL. + refresh-message-position: true + update-only: true \ No newline at end of file diff --git a/.gitignore b/.gitignore index a7c492d655..9c5da98efc 100644 --- a/.gitignore +++ b/.gitignore @@ -4,6 +4,9 @@ secure # Production build +playwright-report +blob-report +test-results # Generated files .docusaurus @@ -37,4 +40,7 @@ package-lock.json /assembly/.env # Tests -test-results/* \ No newline at end of file +test-results/* + +# screenshots +screenshots diff --git a/package.json b/package.json index cd6cfece5a..b6af51a455 100644 --- a/package.json +++ b/package.json @@ -20,6 +20,7 @@ "start": "docusaurus start", "swizzle": "docusaurus swizzle", "vale": "./assembly/run-vale.sh", + "test:visual": "playwright test", "write-heading-ids": "docusaurus write-heading-ids", "write-translations": "docusaurus write-translations", "e2e:install": "npx playwright install --with-deps", @@ -72,6 +73,7 @@ "wait-on": "^7.2.0" }, "devDependencies": { + "@playwright/test": "^1.55.1", "dprint": "^0.45.0", "eslint": "^7.32.0", "eslint-plugin-react": "^7.23.2", diff --git a/playwright.config.ts b/playwright.config.ts index 8cd11b28c7..e69de29bb2 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -1,36 +0,0 @@ -import { defineConfig, devices } from '@playwright/test'; - -const PORT = Number(process.env.PORT ?? 3000); -const BASE_URL = process.env.PLAYWRIGHT_BASE_URL ?? `http://localhost:${PORT}`; - -export default defineConfig({ - testDir: './tests/playwright', - timeout: 60_000, - expect: { - timeout: 10_000, - }, - fullyParallel: true, - forbidOnly: !!process.env.CI, - retries: process.env.CI ? 2 : 0, - workers: process.env.CI ? 1 : undefined, - use: { - baseURL: BASE_URL, - trace: 'on-first-retry', - screenshot: 'only-on-failure', - video: 'retain-on-failure', - }, - webServer: process.env.PLAYWRIGHT_BASE_URL - ? undefined - : { - command: 'yarn start', - url: BASE_URL, - reuseExistingServer: !process.env.CI, - timeout: 120_000, - }, - projects: [ - { - name: 'chromium-desktop', - use: { ...devices['Desktop Chrome'] }, - }, - ], -}); diff --git a/static/scripts/screenshot-artifacts.sh b/static/scripts/screenshot-artifacts.sh new file mode 100644 index 0000000000..042caf24ea --- /dev/null +++ b/static/scripts/screenshot-artifacts.sh @@ -0,0 +1,76 @@ +#!/usr/bin/env bash +set -euo pipefail + +TARGET_DIR="${1:-screenshots}" +WORKFLOW_FILE="${WORKFLOW_FILE:-screenshot-capture.yml}" +REPO="${GITHUB_REPOSITORY:?GITHUB_REPOSITORY is not set}" +TOKEN="${GITHUB_TOKEN:?GITHUB_TOKEN is not set}" +DEFAULT_BRANCH="${GITHUB_BASE_REF:-${GITHUB_REF_NAME:-}}" + +if [[ -z "${DEFAULT_BRANCH}" ]]; then + DEFAULT_BRANCH=$(curl -sSL \ + -H "Authorization: Bearer ${TOKEN}" \ + -H "Accept: application/vnd.github+json" \ + "https://api.github.com/repos/${REPO}" | jq -r '.default_branch') +fi + +mkdir -p "${TARGET_DIR}" + +api() { + local endpoint="$1" + curl -sSL \ + -H "Authorization: Bearer ${TOKEN}" \ + -H "Accept: application/vnd.github+json" \ + "https://api.github.com/repos/${REPO}/${endpoint}" +} + +# Fetch the most recent successful workflow run on the default branch +RUN_ID=$(api "actions/workflows/${WORKFLOW_FILE}/runs?branch=${DEFAULT_BRANCH}&status=completed&per_page=50" \ + | jq -r '.workflow_runs | map(select(.conclusion == "success")) | first | .id') + +if [[ -z "${RUN_ID}" || "${RUN_ID}" == "null" ]]; then + echo "No successful workflow runs found for ${WORKFLOW_FILE} on branch ${DEFAULT_BRANCH}" >&2 + exit 1 +fi + +ARTIFACTS=$(api "actions/runs/${RUN_ID}/artifacts?per_page=100") +COUNT=$(echo "${ARTIFACTS}" | jq '.total_count') + +if [[ "${COUNT}" -eq 0 ]]; then + echo "No artifacts available for run ${RUN_ID}." >&2 + exit 1 +fi + +echo "Downloading ${COUNT} artifact(s) from run ${RUN_ID}..." + +INDEX=0 +while IFS= read -r artifact; do + NAME=$(echo "${artifact}" | jq -r '.name') + URL=$(echo "${artifact}" | jq -r '.archive_download_url') + if [[ -z "${URL}" || "${URL}" == "null" ]]; then + echo "Skipping artifact ${NAME} with no download URL" >&2 + continue + fi + + TMP_ZIP="$(mktemp)" + TMP_DIR="$(mktemp -d)" + curl -sSL -H "Authorization: Bearer ${TOKEN}" -H "Accept: application/vnd.github+json" "${URL}" -o "${TMP_ZIP}" + unzip -oq "${TMP_ZIP}" -d "${TMP_DIR}" + + if [[ -d "${TMP_DIR}/screenshots" ]]; then + shopt -s dotglob + mkdir -p "${TARGET_DIR}" + cp -R "${TMP_DIR}/screenshots"/* "${TARGET_DIR}/" 2>/dev/null || true + shopt -u dotglob + else + DEST_DIR="${TARGET_DIR}/${NAME}" + mkdir -p "${DEST_DIR}" + cp -R "${TMP_DIR}"/* "${DEST_DIR}/" 2>/dev/null || true + fi + + rm -rf "${TMP_ZIP}" "${TMP_DIR}" + INDEX=$((INDEX + 1)) + echo "Downloaded artifact ${NAME}" +done < <(echo "${ARTIFACTS}" | jq -c '.artifacts[]') + +echo "Downloaded ${INDEX} artifact(s) to ${TARGET_DIR}". \ No newline at end of file diff --git a/tests/playwright/exclude.json b/tests/playwright/exclude.json new file mode 100644 index 0000000000..0637a088a0 --- /dev/null +++ b/tests/playwright/exclude.json @@ -0,0 +1 @@ +[] \ No newline at end of file diff --git a/tests/playwright/screenshot.doc.spec.ts b/tests/playwright/screenshot.doc.spec.ts new file mode 100644 index 0000000000..3b3ef9abbf --- /dev/null +++ b/tests/playwright/screenshot.doc.spec.ts @@ -0,0 +1,71 @@ +import fs from 'fs'; +import path from 'path'; +import { expect, test } from '@playwright/test'; +import { extractSitemapPathnames, WaitForDocusaurusHydration } from './utils'; + +const siteUrl = process.env.PLAYWRIGHT_BASE_URL ?? 'http://localhost:3000'; +const sitemapPath = path.resolve('build/sitemap.xml'); +const stylesheetPath = path.resolve('visuals/screenshot.css'); +const stylesheet = fs.existsSync(stylesheetPath) ? fs.readFileSync(stylesheetPath).toString() : ''; +const excludeList: string[] = require('./exclude.json'); + +test.describe.configure({ mode: 'parallel' }); + +function isVersionedDocsPathname(pathname: string, list: string[]): boolean { + if (list.includes(pathname)) { + console.log(`Excluding ${pathname}`); + return false; + } + + if (pathname.startsWith('/api/')) { + return false; + } + + if (/\/\d+\.\d+\.x\//.test(pathname)) { + return false; + } + + return true; +} + +function sanitizePathname(pathname: string): string { + const cleaned = pathname.replace(/^\/+/, '').replace(/[^a-zA-Z0-9]+/g, '_'); + return cleaned === '' ? 'home' : cleaned; +} + +test.beforeAll(async () => { + console.log('Excluded pages: ', excludeList); + console.log('Total pages: ', extractSitemapPathnames(sitemapPath).length); +}); + +function screenshotPathname(pathname: string) { + test(`pathname ${pathname}`, async ({ page }, testInfo) => { + const url = siteUrl + pathname; + console.log(`Taking screenshot of ${url}`); + + await page.goto(url, { waitUntil: 'domcontentloaded' }); + await page.waitForFunction(WaitForDocusaurusHydration); + await page.waitForLoadState('networkidle'); + + if (stylesheet) { + await page.addStyleTag({ content: stylesheet }); + } + + await page.waitForTimeout(1_000); + + const snapshotName = `${sanitizePathname(pathname)}.png`; + await expect(page).toHaveScreenshot({ + fullPage: true, + path: testInfo.snapshotPath(snapshotName), + timeout: 10_000, + }); + }); +} + +test.describe('Docs screenshots', () => { + const pathnames = extractSitemapPathnames(sitemapPath).filter((pathname) => + isVersionedDocsPathname(pathname, excludeList) + ); + + pathnames.forEach(screenshotPathname); +}); \ No newline at end of file diff --git a/tests/playwright/utils.ts b/tests/playwright/utils.ts new file mode 100644 index 0000000000..64efd8d79d --- /dev/null +++ b/tests/playwright/utils.ts @@ -0,0 +1,46 @@ +import fs from 'fs'; +import path from 'path'; + +const LOC_TAG_REGEX = /([^<]+)<\/loc>/gi; + +export function extractSitemapPathnames(sitemapPath: string): string[] { + const resolvedPath = path.resolve(sitemapPath); + if (!fs.existsSync(resolvedPath)) { + console.warn(`Sitemap not found at ${resolvedPath}`); + return []; + } + + const xml = fs.readFileSync(resolvedPath, 'utf8'); + const seen = new Set(); + + for (const match of xml.matchAll(LOC_TAG_REGEX)) { + const loc = match[1]; + try { + const url = new URL(loc); + const pathname = url.pathname || '/'; + seen.add(pathname.endsWith('/') && pathname.length > 1 ? pathname.slice(0, -1) : pathname); + } catch (error) { + console.warn(`Skipping invalid sitemap location: ${loc}`); + } + } + + return Array.from(seen).sort(); +} + +export function WaitForDocusaurusHydration() { + const root = document.querySelector('#__docusaurus'); + if (!root) { + return false; + } + + const html = document.documentElement; + if (!html) { + return false; + } + + const hasDocusaurusObject = typeof (window as any).__docusaurus !== 'undefined'; + const hasClientContent = root.querySelector('[data-theme]') !== null; + const hasNavbar = document.querySelector('.navbar') !== null; + + return hasDocusaurusObject || hasClientContent || hasNavbar; +} \ No newline at end of file diff --git a/visuals/docs-home.spec.ts b/visuals/docs-home.spec.ts new file mode 100644 index 0000000000..627e3761a6 --- /dev/null +++ b/visuals/docs-home.spec.ts @@ -0,0 +1,18 @@ +import { expect, test } from "@playwright/test"; + +const basePath = "/"; + +// Disable animations so captured screenshots are stable. +const screenshotOptions = { + animations: "disabled" as const, + fullPage: true, +}; + +test.describe("Documentation visuals", () => { + test("homepage", async ({ page }) => { + await page.goto(basePath); + await page.waitForLoadState("networkidle"); + + await expect(page).toHaveScreenshot("homepage.png", screenshotOptions); + }); +}); diff --git a/visuals/screenshot.css b/visuals/screenshot.css new file mode 100644 index 0000000000..629e7ce538 --- /dev/null +++ b/visuals/screenshot.css @@ -0,0 +1,19 @@ +/* Reduce animation noise during visual regression runs */ +*, +*::before, +*::after { + transition-duration: 0.01ms !important; + transition-delay: 0s !important; + animation-duration: 0.01ms !important; + animation-delay: -0.01ms !important; + animation-iteration-count: 1 !important; +} + +html, +body { + scroll-behavior: auto !important; +} + +[data-theme][data-has-mounted='false'] { + opacity: 1 !important; +} \ No newline at end of file diff --git a/yarn.lock b/yarn.lock index b78e4f19ac..73833f1f70 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2347,7 +2347,7 @@ dependencies: "@octokit/openapi-types" "^12.11.0" -"@playwright/test@^1.47.0": +"@playwright/test@^1.55.1": version "1.55.1" resolved "https://registry.yarnpkg.com/@playwright/test/-/test-1.55.1.tgz#80f775d5f948cd3ef550fcc45ef99986d3ffb36c" integrity sha512-IVAh/nOJaw6W9g+RJVlIQJ6gSiER+ae6mKQ5CX1bERzQgbC1VSeBlwdvczT7pxb0GWiyrxH4TGKbMfDb4Sq/ig==