-
Notifications
You must be signed in to change notification settings - Fork 5
ci: smoke tests using wwebjs #222
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| cypress.config.ts |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,63 @@ | ||
| name: Whatsapp Web JS - Flow Smoke Test | ||
|
|
||
| permissions: | ||
| contents: read | ||
|
|
||
| on: | ||
| schedule: | ||
| - cron: '*/30 * * * *' | ||
| workflow_dispatch: | ||
| inputs: | ||
| environment: | ||
| description: 'Target environment' | ||
| required: true | ||
| default: 'production' | ||
| type: choice | ||
| options: | ||
| - production | ||
| - staging | ||
|
|
||
| concurrency: | ||
| group: whatsapp-smoke-${{ github.event.inputs.environment || 'production' }} | ||
| cancel-in-progress: false | ||
|
|
||
| jobs: | ||
| wwebjs-smoke: | ||
| runs-on: ubuntu-latest | ||
| timeout-minutes: 10 | ||
|
|
||
| steps: | ||
| - uses: actions/checkout@v4 | ||
|
|
||
| - uses: actions/setup-node@v4 | ||
| with: | ||
| node-version: '22' | ||
|
|
||
| - name: Install Chromium dependencies | ||
| run: | | ||
| sudo apt-get update -qq | ||
| sudo apt-get install -y \ | ||
| libnss3 libatk1.0-0 libatk-bridge2.0-0 libcups2 \ | ||
| libdrm2 libxkbcommon0 libxcomposite1 libxdamage1 \ | ||
| libxrandr2 libgbm1 libpango-1.0-0 libcairo2 libasound2 | ||
|
|
||
| - name: Install dependencies | ||
| run: yarn install | ||
|
|
||
| - name: Write GCP credentials | ||
| run: echo '${{ secrets.GCP_SA_KEY_JSON }}' > /tmp/gcp-key.json | ||
|
|
||
| - name: Run WhatsApp flow test | ||
| env: | ||
| WHATSAPP_TARGET_ENV: ${{ github.event.inputs.environment || 'production' }} | ||
| GCS_BUCKET: ${{ secrets.GCS_BUCKET }} | ||
| GCS_KEY_FILE: /tmp/gcp-key.json | ||
| INSTATUS_API_KEY: ${{ secrets.INSTATUS_API_KEY }} | ||
| INSTATUS_PAGE_ID: ${{ secrets.INSTATUS_PAGE_ID }} | ||
| INSTATUS_COMPONENT_ID: ${{ secrets.INSTATUS_COMPONENT_ID }} | ||
| BOT_PHONE_NUMBER: ${{ secrets.BOT_PHONE_NUMBER }} | ||
| run: yarn wa:smoke | ||
|
|
||
| - name: Clean up credentials | ||
| if: always() | ||
| run: rm -f /tmp/gcp-key.json | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,79 @@ | ||
| describe('smoke test', () => { | ||
| let testPassed = true; | ||
|
|
||
| afterEach(function () { | ||
| if (this.currentTest?.state === 'failed') { | ||
| testPassed = false; | ||
| } | ||
| }); | ||
|
|
||
| after(() => { | ||
| cy.env(['INSTATUS_API_KEY', 'INSTATUS_PAGE_ID', 'INSTATUS_COMPONENT_ID']).then( | ||
| ({ INSTATUS_API_KEY, INSTATUS_PAGE_ID, INSTATUS_COMPONENT_ID }) => { | ||
| const status = testPassed ? 'OPERATIONAL' : 'MAJOROUTAGE'; | ||
| cy.request({ | ||
| method: 'PUT', | ||
| url: `https://api.instatus.com/v2/${INSTATUS_PAGE_ID}/components/${INSTATUS_COMPONENT_ID}`, | ||
| headers: { Authorization: `Bearer ${INSTATUS_API_KEY}` }, | ||
| body: { status }, | ||
| failOnStatusCode: false, | ||
| }); | ||
| } | ||
| ); | ||
| }); | ||
|
|
||
| it('passes', () => { | ||
| cy.env([ | ||
| 'SMOKE_TEST_CHAT_URL', | ||
| 'SMOKE_TEST_LOGIN_PHONE_NUMBER', | ||
| 'SMOKE_TEST_LOGIN_PASSWORD', | ||
| ]).then(({ SMOKE_TEST_CHAT_URL, SMOKE_TEST_LOGIN_PHONE_NUMBER, SMOKE_TEST_LOGIN_PASSWORD }) => { | ||
| cy.visit(SMOKE_TEST_CHAT_URL); | ||
| cy.get('[data-testid="phoneInput"] [name="phoneNumber"]').click(); | ||
| cy.get('[data-testid="phoneInput"] [name="phoneNumber"]').type(SMOKE_TEST_LOGIN_PHONE_NUMBER); | ||
| cy.get('[data-testid="outlinedInput"] [name="password"]').click(); | ||
| cy.get('[data-testid="outlinedInput"] [name="password"]').click(); | ||
|
Comment on lines
+34
to
+35
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Remove redundant click. The password field is clicked twice before typing. Only one click is needed to focus the field. 🔧 Proposed fix- cy.get('[data-testid="outlinedInput"] [name="password"]').click();
cy.get('[data-testid="outlinedInput"] [name="password"]').click();
cy.get('[data-testid="outlinedInput"] [name="password"]').type(SMOKE_TEST_LOGIN_PASSWORD);🤖 Prompt for AI Agents |
||
| cy.get('[data-testid="outlinedInput"] [name="password"]').type(SMOKE_TEST_LOGIN_PASSWORD); | ||
| cy.get('[data-testid="SubmitButton"]').click(); | ||
| cy.get('[data-testid="dropdownIcon"]', { timeout: 10000 }).should('be.visible').click(); | ||
| cy.get('[data-testid="flowButton"]', { timeout: 10000 }).should('be.visible').click(); | ||
|
|
||
| cy.get('div[data-testid="AutocompleteInput"]', { timeout: 10000 }) | ||
| .should('be.visible') | ||
| .within(() => { | ||
| cy.get('input').type('smoke-test'); | ||
| }); | ||
| cy.get('ul.MuiAutocomplete-listbox') | ||
| .first() | ||
| .within(() => { | ||
| cy.get('li').first().click(); | ||
| }); | ||
|
|
||
| cy.get('[data-testid="ok-button"]').click(); | ||
|
|
||
| // Wait for 30 seconds to ensure all messages are in | ||
| cy.wait(30000); | ||
|
|
||
| cy.get('[data-testid="messageContainer"] [data-testid="content"]', { timeout: 10000 }).then( | ||
| ($messages) => { | ||
| expect($messages.length).to.be.at.least(3); | ||
| const lastThree = $messages.slice(-3); | ||
|
|
||
| // First of last three should have the date message | ||
| const now = new Date(); | ||
| const year = now.getFullYear(); | ||
| const month = String(now.getMonth() + 1).padStart(2, '0'); | ||
| const day = String(now.getDate()).padStart(2, '0'); | ||
| const dateString = `World! ${day}/${month}/${year}`; | ||
| expect(Cypress.$(lastThree[0]).text()).to.contain(dateString); | ||
|
Comment on lines
+63
to
+68
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Date assertion may cause flaky failures. The test constructs the expected date string from
Consider either:
🤖 Prompt for AI Agents |
||
|
|
||
| // Second should have 'elephant' | ||
| expect(Cypress.$(lastThree[1]).text()).to.contain('elephant'); | ||
|
|
||
| // Third should have an audio element inside | ||
| expect(Cypress.$(lastThree[2]).find('audio').length).to.be.greaterThan(0); | ||
| } | ||
| ); | ||
| }); | ||
| }); | ||
| }); | ||
| Original file line number | Diff line number | Diff line change | ||||||||
|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,62 @@ | ||||||||||
| export interface ExpectedMessage { | ||||||||||
| containsText?: string; | ||||||||||
| exactText?: string; | ||||||||||
| hasMedia?: boolean; | ||||||||||
| mediaType?: 'image' | 'audio' | 'video' | 'document' | 'sticker'; | ||||||||||
| } | ||||||||||
|
|
||||||||||
| export interface FlowDefinition { | ||||||||||
| id: string; | ||||||||||
| description: string; | ||||||||||
| triggerMessage: string; | ||||||||||
| expectedResponses: ExpectedMessage[]; | ||||||||||
| timeoutMs?: number; | ||||||||||
| } | ||||||||||
|
|
||||||||||
| export interface TargetEnvironment { | ||||||||||
| name: string; | ||||||||||
| botPhoneNumber: string; | ||||||||||
| sessionId: string; | ||||||||||
| gcsBucket: string; | ||||||||||
| instatus: { | ||||||||||
| pageId: string; | ||||||||||
| componentId: string; | ||||||||||
| }; | ||||||||||
| } | ||||||||||
|
|
||||||||||
| export const FLOWS: Record<string, FlowDefinition> = { | ||||||||||
| 'smoke-test': { | ||||||||||
| id: 'smoke-test', | ||||||||||
| description: 'Verify core flow: date message → elephant text → audio', | ||||||||||
| triggerMessage: 'smoke', | ||||||||||
| expectedResponses: [ | ||||||||||
| { containsText: 'World!' }, | ||||||||||
| { containsText: 'elephant' }, | ||||||||||
| { hasMedia: true, mediaType: 'audio' }, | ||||||||||
| ], | ||||||||||
| timeoutMs: 120_000, | ||||||||||
| }, | ||||||||||
| }; | ||||||||||
|
|
||||||||||
| export const ENVIRONMENTS: Record<string, TargetEnvironment> = { | ||||||||||
| production: { | ||||||||||
| name: 'production', | ||||||||||
| botPhoneNumber: process.env.BOT_PHONE_NUMBER ?? '+918657048982', | ||||||||||
| sessionId: process.env.WHATSAPP_SESSION_ID ?? 'production-sender', | ||||||||||
|
Comment on lines
+44
to
+45
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Remove the hard-coded production phone fallback. If Suggested fix- botPhoneNumber: process.env.BOT_PHONE_NUMBER ?? '+918657048982',
+ botPhoneNumber: process.env.BOT_PHONE_NUMBER ?? '',📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||
| gcsBucket: process.env.GCS_BUCKET ?? '', | ||||||||||
| instatus: { | ||||||||||
| pageId: process.env.INSTATUS_PAGE_ID ?? '', | ||||||||||
| componentId: process.env.INSTATUS_COMPONENT_ID ?? '', | ||||||||||
| }, | ||||||||||
| }, | ||||||||||
| staging: { | ||||||||||
| name: 'staging', | ||||||||||
| botPhoneNumber: process.env.BOT_PHONE_NUMBER ?? '', | ||||||||||
| sessionId: process.env.WHATSAPP_SESSION_ID ?? 'staging-sender', | ||||||||||
| gcsBucket: process.env.GCS_BUCKET ?? '', | ||||||||||
| instatus: { | ||||||||||
| pageId: process.env.INSTATUS_PAGE_ID ?? '', | ||||||||||
| componentId: process.env.INSTATUS_COMPONENT_ID ?? '', | ||||||||||
| }, | ||||||||||
| }, | ||||||||||
| }; | ||||||||||
| Original file line number | Diff line number | Diff line change | ||||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
| @@ -0,0 +1,78 @@ | ||||||||||||||||
| import { Storage } from '@google-cloud/storage'; | ||||||||||||||||
| import * as fs from 'fs'; | ||||||||||||||||
| import * as path from 'path'; | ||||||||||||||||
|
|
||||||||||||||||
| export interface GCSStoreOptions { | ||||||||||||||||
| bucket: string; | ||||||||||||||||
| keyFilename?: string; | ||||||||||||||||
| prefix?: string; | ||||||||||||||||
| /** Must match RemoteAuth dataPath (default: ./.wwebjs_auth/) */ | ||||||||||||||||
| dataPath?: string; | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| export interface StoreSessionParams { | ||||||||||||||||
| session: string; | ||||||||||||||||
| path?: string; | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| export class GCSRemoteAuthStore { | ||||||||||||||||
| private storage: Storage; | ||||||||||||||||
| private bucket: string; | ||||||||||||||||
| private prefix: string; | ||||||||||||||||
| private dataPath: string; | ||||||||||||||||
|
|
||||||||||||||||
| constructor(options: GCSStoreOptions) { | ||||||||||||||||
| this.storage = new Storage(options.keyFilename ? { keyFilename: options.keyFilename } : {}); | ||||||||||||||||
| this.bucket = options.bucket; | ||||||||||||||||
| this.prefix = options.prefix ?? 'whatsapp-sessions'; | ||||||||||||||||
| this.dataPath = path.resolve(options.dataPath ?? './.wwebjs_auth'); | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| /** RemoteAuth writes zips as {dataPath}/{session}.zip (e.g. RemoteAuth-production-sender.zip). */ | ||||||||||||||||
| private localZipPath(session: string): string { | ||||||||||||||||
| return path.join(this.dataPath, `${session}.zip`); | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| private objectName(session: string): string { | ||||||||||||||||
| return `${this.prefix}/${session}.zip`; | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| async sessionExists({ session }: { session: string }): Promise<boolean> { | ||||||||||||||||
| console.log(`Checking if session exists: ${session}`); | ||||||||||||||||
| const [exists] = await this.storage.bucket(this.bucket).file(this.objectName(session)).exists(); | ||||||||||||||||
| return exists; | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| async save({ session }: StoreSessionParams): Promise<void> { | ||||||||||||||||
| console.log(`Saving session: ${session}`); | ||||||||||||||||
| const localZip = this.localZipPath(session); | ||||||||||||||||
| if (!fs.existsSync(localZip)) { | ||||||||||||||||
| throw new Error(`Session zip not found at ${localZip}`); | ||||||||||||||||
| } | ||||||||||||||||
| await this.storage | ||||||||||||||||
| .bucket(this.bucket) | ||||||||||||||||
| .upload(localZip, { destination: this.objectName(session) }); | ||||||||||||||||
| console.log(`Session saved to GCS: gs://${this.bucket}/${this.objectName(session)}`); | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| async extract({ session, path: destPath }: StoreSessionParams): Promise<void> { | ||||||||||||||||
| console.log(`Extracting session: ${session}`); | ||||||||||||||||
| const localZip = destPath ?? this.localZipPath(session); | ||||||||||||||||
| fs.mkdirSync(path.dirname(localZip), { recursive: true }); | ||||||||||||||||
| await this.storage | ||||||||||||||||
| .bucket(this.bucket) | ||||||||||||||||
| .file(this.objectName(session)) | ||||||||||||||||
| .download({ destination: localZip }); | ||||||||||||||||
| console.log(`Session restored from GCS: gs://${this.bucket}/${this.objectName(session)}`); | ||||||||||||||||
| } | ||||||||||||||||
|
|
||||||||||||||||
| async delete({ session }: { session: string }): Promise<void> { | ||||||||||||||||
| console.log(`Deleting session: ${session}`); | ||||||||||||||||
| try { | ||||||||||||||||
| await this.storage.bucket(this.bucket).file(this.objectName(session)).delete(); | ||||||||||||||||
| } catch (err: unknown) { | ||||||||||||||||
| if ((err as NodeJS.ErrnoException)?.code !== '404') throw err; | ||||||||||||||||
| } | ||||||||||||||||
|
Comment on lines
+73
to
+75
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🌐 Web query:
💡 Result: When you call Citations:
Handle GCS “not found” errors by checking numeric HTTP 404.
Suggested fix- } catch (err: unknown) {
- if ((err as NodeJS.ErrnoException)?.code !== '404') throw err;
+ } catch (err: unknown) {
+ const e = err as { code?: number; response?: { statusCode?: number } };
+ if (e.code !== 404 && e.response?.statusCode !== 404) throw err;
}📝 Committable suggestion
Suggested change
🤖 Prompt for AI Agents |
||||||||||||||||
| console.log(`Session deleted from GCS: ${this.objectName(session)}`); | ||||||||||||||||
| } | ||||||||||||||||
| } | ||||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,26 @@ | ||
| export async function reportToInstatus( | ||
| apiKey: string, | ||
| pageId: string, | ||
| componentId: string, | ||
| passed: boolean | ||
| ): Promise<void> { | ||
| if (!apiKey || !pageId || !componentId) { | ||
| console.warn('Instatus credentials not set — skipping status report'); | ||
| return; | ||
| } | ||
| const status = passed ? 'OPERATIONAL' : 'MAJOROUTAGE'; | ||
| const url = `https://api.instatus.com/v2/${pageId}/components/${componentId}`; | ||
| try { | ||
| const res = await fetch(url, { | ||
| method: 'PUT', | ||
| headers: { | ||
| Authorization: `Bearer ${apiKey}`, | ||
| 'Content-Type': 'application/json', | ||
| }, | ||
| body: JSON.stringify({ status }), | ||
| }); | ||
| console.log(`Instatus report: ${status} → HTTP ${res.status}`); | ||
| } catch (err) { | ||
|
Comment on lines
+14
to
+23
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. 🧩 Analysis chain🌐 Web query:
💡 Result: Yes. For Instatus v2 component update endpoints (PUT /v2/:page_id/components/:component_id), you should treat any non-2xx HTTP response as a failed update that clients must handle explicitly. Instatus’s API documentation states that “Responses from the Instatus API, including errors, are encoded as JSON” and that error responses include a code and message in the payload (so clients should not assume that only the request transport succeeded) [1]. For component updates, the API reference documents the update endpoint and shows a normal 200-style success example response when the update succeeds [2][3]. Given that Instatus uses explicit error payloads with an “error” object (code/message) and that non-2xx status codes are categorized by HTTP as client/server errors rather than successful responses [4], a non-2xx response indicates the server did not complete the update. Practical client guidance: consider the update successful only when the HTTP status is in the 2xx range and you can parse the success response body; otherwise, read the JSON error payload (error.code / error.message) and handle/retry/alert as appropriate [1]. Citations:
🏁 Script executed: #!/bin/bash
set -euo pipefail
# Inspect the referenced file around the stated lines
ls -la
sed -n '1,120p' wwebjs/smoke/instatus.ts
# Check for any other fetch/error handling patterns in the repo
rg -n "instatus.*fetch|fetch\\(url|res\\.ok|HTTP .*res\\.status|reported|Instatus report" wwebjs/smoke/instatus.ts wwebjs -SRepository: glific/cypress-testing Length of output: 2434 Treat non-2xx Instatus responses as failures.
Suggested fix const res = await fetch(url, {
method: 'PUT',
headers: {
Authorization: `Bearer ${apiKey}`,
'Content-Type': 'application/json',
},
body: JSON.stringify({ status }),
});
- console.log(`Instatus report: ${status} → HTTP ${res.status}`);
+ if (!res.ok) {
+ throw new Error(`Instatus report failed with HTTP ${res.status}`);
+ }
+ console.log(`Instatus report: ${status} → HTTP ${res.status}`);🤖 Prompt for AI Agents |
||
| console.error('Instatus report failed:', err); | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Set restrictive permissions on the credentials file.
Writing the GCP service account key to a file without explicit permissions could expose it to other processes. While GitHub Actions runners are isolated, it's best practice to restrict file permissions.
🔒 Proposed fix
📝 Committable suggestion
🤖 Prompt for AI Agents