test(e2e): massive rework of e2e tests

now they run as-is, without any bundling. this might seem like a downgrade, but we no longer really need to verify that we publish stuff correctly as we delegate that to `@fuman/build`

meow
This commit is contained in:
alina 🌸 2024-12-06 00:14:29 +03:00
parent f0451d56e3
commit af54f6e1c3
Signed by: teidesu
SSH key fingerprint: SHA256:uNeCpw6aTSU4aIObXLvHfLkDa82HWH9EiOj9AXOIRpI
99 changed files with 1147 additions and 2647 deletions

View file

@ -1,7 +1,6 @@
import * as vitestExpect from '@vitest/expect' import * as vitestExpect from '@vitest/expect'
import * as vitestSpy from '@vitest/spy' import * as vitestSpy from '@vitest/spy'
import { afterAll, afterEach, beforeAll, beforeEach, vi as bunVi, it, jest } from 'bun:test' import { afterAll, afterEach, beforeAll, beforeEach, vi as bunVi, it, jest } from 'bun:test'
// https://github.com/oven-sh/bun/issues/6044
import * as chai from 'chai' import * as chai from 'chai'
import { setupChai, stubGlobal, unstubAllGlobals, waitFor } from './polyfills' import { setupChai, stubGlobal, unstubAllGlobals, waitFor } from './polyfills'

View file

@ -55,7 +55,7 @@ export default defineConfig({
name: 'polyfills', name: 'polyfills',
transform(code) { transform(code) {
if (!code.includes('vitest')) return code if (!code.includes('vitest')) return code
code = code.replace(/^import \{(.+?)\} from ['"]vitest['"]/gms, (_, names) => { code = code.replace(/^import \{([^}]+)\} from ['"]vitest['"];?$/gm, (_, names) => {
const namesParsed = names.split(',').map(name => name.trim()) const namesParsed = names.split(',').map(name => name.trim())
const namesFromFixup: string[] = [] const namesFromFixup: string[] = []

View file

@ -54,7 +54,7 @@ export default defineConfig({
name: 'polyfills', name: 'polyfills',
transform(code) { transform(code) {
if (!code.includes('vitest')) return code if (!code.includes('vitest')) return code
code = code.replace(/^import \{(.+?)\} from ['"]vitest['"]/gms, (_, names) => { code = code.replace(/^import \{([^}]+)\} from ['"]vitest['"];?$/gm, (_, names) => {
const namesParsed = names.split(',').map(name => name.trim()) const namesParsed = names.split(',').map(name => name.trim())
return `import {${namesParsed.join(', ')}} from '${POLYFILLS}'` return `import {${namesParsed.join(', ')}} from '${POLYFILLS}'`

View file

@ -69,11 +69,11 @@ jobs:
- uses: ./.github/actions/init - uses: ./.github/actions/init
- uses: denoland/setup-deno@v1 - uses: denoland/setup-deno@v1
with: with:
deno-version: '1.46.3' deno-version: '2.0'
- name: 'Build tests' - name: 'Build tests'
run: pnpm exec vite build -c .config/vite.deno.ts run: pnpm exec vite build -c .config/vite.deno.ts
- name: 'Run tests' - name: 'Run tests'
run: cd dist/tests && deno test -A --unstable-ffi run: cd dist/tests && deno test -A --unstable-ffi --node-modules-dir=false
test-web: test-web:
runs-on: ubuntu-latest runs-on: ubuntu-latest
@ -102,12 +102,15 @@ jobs:
actions: write actions: write
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: ./.github/actions/init
- name: Run end-to-end tests - name: Run end-to-end tests
env: env:
API_ID: ${{ secrets.TELEGRAM_API_ID }} API_ID: ${{ secrets.TELEGRAM_API_ID }}
API_HASH: ${{ secrets.TELEGRAM_API_HASH }} API_HASH: ${{ secrets.TELEGRAM_API_HASH }}
SESSION_DC1: ${{ secrets.SESSION_DC1 }}
SESSION_DC2: ${{ secrets.SESSION_DC2 }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
run: cd e2e/node && ./cli.sh ci run: 'cd e2e && pnpm run test:all'
e2e-deno: e2e-deno:
runs-on: ubuntu-latest runs-on: ubuntu-latest
needs: [lint, test-node, test-web, test-bun, test-deno] needs: [lint, test-node, test-web, test-bun, test-deno]
@ -116,17 +119,18 @@ jobs:
actions: write actions: write
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- uses: ./.github/actions/init
- uses: denoland/setup-deno@v1
with:
deno-version: '2.0'
- name: Run end-to-end tests under Deno - name: Run end-to-end tests under Deno
env: env:
API_ID: ${{ secrets.TELEGRAM_API_ID }} API_ID: ${{ secrets.TELEGRAM_API_ID }}
API_HASH: ${{ secrets.TELEGRAM_API_HASH }} API_HASH: ${{ secrets.TELEGRAM_API_HASH }}
SESSION_DC1: ${{ secrets.SESSION_DC1 }}
SESSION_DC2: ${{ secrets.SESSION_DC2 }}
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
uses: nick-fields/retry@v2 run: 'cd e2e && pnpm run deno:test:all'
# thanks docker networking very cool
with:
max_attempts: 3
timeout_minutes: 30
command: cd e2e/deno && ./cli.sh ci
cr: cr:
needs: needs:

9
e2e/.env.example Normal file
View file

@ -0,0 +1,9 @@
# obtain these values from my.telegram.org
API_ID=
API_HASH=
# mtcute session strings for test dc1 and test dc2
SESSION_DC1=
SESSION_DC2=
GITHUB_TOKEN=

1
e2e/.gitignore vendored Normal file
View file

@ -0,0 +1 @@
.env

2
e2e/_runtime/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
*
!.gitignore

50
e2e/deno.lock Normal file
View file

@ -0,0 +1,50 @@
{
"version": "4",
"specifiers": {
"jsr:@fuman/utils@0.0.1": "0.0.1",
"jsr:@std/assert@^1.0.8": "1.0.8",
"jsr:@std/internal@^1.0.5": "1.0.5",
"jsr:@std/testing@1.0.5": "1.0.5"
},
"jsr": {
"@fuman/utils@0.0.1": {
"integrity": "7cf43898814272c0918e813b34a1ae848ded5710383018dacd27a8fbb1dd6437"
},
"@std/assert@1.0.8": {
"integrity": "ebe0bd7eb488ee39686f77003992f389a06c3da1bbd8022184804852b2fa641b"
},
"@std/internal@1.0.5": {
"integrity": "54a546004f769c1ac9e025abd15a76b6671ddc9687e2313b67376125650dc7ba"
},
"@std/testing@1.0.5": {
"integrity": "6e693cbec94c81a1ad3df668685c7ba8e20742bb10305bc7137faa5cf16d2ec4",
"dependencies": [
"jsr:@std/assert",
"jsr:@std/internal"
]
}
},
"redirects": {
"https://esm.sh/v135/@types/chai@~5/index.d.ts": "https://esm.sh/v135/@types/chai@5.0.1/index.d.ts"
},
"remote": {
"https://esm.sh/chai@5.1.2": "52c79876382aaf6855c55ea66e2ff88675457f4727bfde37363bb199cdda8488",
"https://esm.sh/v135/chai@5.1.2/denonext/chai.mjs": "05cc6071c804cf39d4325a2c93807727623019abedca6ed1cb6534137bd4f65e"
},
"workspace": {
"packageJson": {
"dependencies": [
"npm:@fuman/utils@0.0.1",
"npm:@types/chai@^4.3.8",
"npm:@types/mocha@^10.0.2",
"npm:@types/node@^20.8.10",
"npm:better-sqlite3@11.6.0",
"npm:chai@^4.3.10",
"npm:dotenv-cli@7.4.4",
"npm:esbuild@0.24",
"npm:globstar@1.0.0",
"npm:tsx@^4.19.2"
]
}
}
}

View file

@ -1,3 +0,0 @@
/.jsr-data
/dist
/deno.lock

View file

@ -1,5 +0,0 @@
# obtain these values from my.telegram.org
API_ID=
API_HASH=
GITHUB_TOKEN=

4
e2e/deno/.gitignore vendored
View file

@ -1,4 +0,0 @@
/.jsr-data
.env
/deno.lock
/.sessions

View file

@ -1,24 +0,0 @@
FROM denoland/deno:bin-1.45.5 as deno-bin
FROM node:20
WORKDIR /app
COPY --from=deno-bin /deno /bin/deno
RUN corepack enable && \
corepack prepare pnpm@9.0.6 --activate
COPY ../.. /app/
RUN pnpm install --frozen-lockfile && \
pnpm -C packages/tl run gen-code
RUN apt update && apt install -y socat
ENV REGISTRY="http://jsr/"
ENV E2E="1"
ENV JSR="1"
ENV JSR_TOKEN="token"
ENTRYPOINT [ "node", "/app/scripts/publish.js" ]
CMD [ "all" ]

View file

@ -1,3 +0,0 @@
FROM ghcr.io/teidesu/jsr-api:latest
RUN apt update && apt install -y curl

View file

@ -1,10 +0,0 @@
FROM denoland/deno:1.45.5
WORKDIR /app
RUN apt update && apt install -y socat
COPY ./ /app/
ENV DOCKER="1"
ENTRYPOINT [ "./cli.sh", "run" ]

View file

@ -1,30 +0,0 @@
# mtcute e2e tests (Deno edition)
This directory contains end-to-end tests for mtcute under Deno.
They are made for 2 purposes:
- Ensure published packages work as expected and can properly be imported
- Ensure that the library works with the actual Telegram API
To achieve the first goal, we use a local JSR instance container where we publish the package,
and then install it from there in another container
## Setting up
Before running the tests, you need to copy `.env.example` to `.env` and fill in the values
## Running tests
```bash
# first start a local jsr instance
./cli.sh start
# push all packages to the local registry
./cli.sh update
# pushing a particular package is not supported due to jsr limitations
# run the tests
./cli.sh run
# or in docker
./cli.sh run-docker
```

View file

@ -1,90 +0,0 @@
#!/bin/bash
set -eau
method=$1
shift
case "$method" in
"start")
docker compose up -d --wait jsr
node ./init-server.js
;;
"update")
# unpublish all packages
if [ -d .jsr-data/gcs/modules/@mtcute ]; then
rm -rf .jsr-data/gcs/modules/@mtcute
docker compose exec jsr-db psql registry -U user -c "delete from publishing_tasks;"
docker compose exec jsr-db psql registry -U user -c "delete from package_files;"
docker compose exec jsr-db psql registry -U user -c "delete from npm_tarballs;"
docker compose exec jsr-db psql registry -U user -c "delete from package_version_dependencies;"
docker compose exec jsr-db psql registry -U user -c "delete from package_versions;"
docker compose exec jsr-db psql registry -U user -c "delete from packages;"
fi
# publish all packages
docker compose run --rm --build build all
# clear cache
if command -v deno &> /dev/null; then
rm -rf $(deno info --json | jq .denoDir -r)/deps
fi
if [ -f deno.lock ]; then
rm deno.lock
fi
;;
"clean")
docker compose down
rm -rf .jsr-data
;;
"stop")
docker compose down
;;
"run")
if [ -f .env ]; then
source .env
fi
export JSR_URL=http://localhost:4873
if [ ! -z ${DOCKER+x} ]; then
# running behind a socat proxy seems to fix some of the docker networking issues (thx kamillaova)
socat TCP-LISTEN:4873,fork,reuseaddr TCP4:jsr:80 &
socat_pid=$!
# run `deno cache` with a few retries to make sure everything is cached
for i in {1..5}; do
if deno cache tests/*.ts; then
break
fi
done
trap "kill $socat_pid" EXIT
fi
if [ $# -eq 0 ]; then
deno test -A --unstable-ffi tests/**/*.ts
else
deno test -A --unstable-ffi $@
fi
;;
"run-docker")
source .env
docker compose run --rm --build test $@
;;
"ci")
set -eaux
if [ -d .jsr-data ]; then
# clean up data from previous runs
docker compose down
sudo rm -rf .jsr-data
fi
mkdir .jsr-data
./cli.sh start
./cli.sh update
docker compose run --rm --build test
;;
*)
echo "Unknown command"
;;
esac

View file

@ -1,10 +0,0 @@
{
"imports": {
"@mtcute/web": "jsr:@mtcute/web@*",
"@mtcute/wasm": "jsr:@mtcute/wasm@*",
"@mtcute/tl": "jsr:@mtcute/tl@*",
"@mtcute/tl-runtime": "jsr:@mtcute/tl-runtime@*",
"@mtcute/core": "jsr:@mtcute/core@*",
"@mtcute/deno": "jsr:@mtcute/deno@*"
}
}

View file

@ -1,78 +0,0 @@
version: "3"
services:
# jsr (based on https://github.com/teidesu/docker-images/blob/main/jsr/docker-compose.yaml)
jsr-db:
image: postgres:15
command: postgres -c 'max_connections=1000'
restart: always
environment:
POSTGRES_USER: user
POSTGRES_PASSWORD: password
POSTGRES_DB: registry
healthcheck:
test: "pg_isready -U $$POSTGRES_USER -d $$POSTGRES_DB"
interval: 5s
retries: 20
start_period: 5s
volumes:
- ./.jsr-data/db:/var/lib/postgresql/data
jsr-gcs:
image: fsouza/fake-gcs-server:latest
command: -scheme http -filesystem-root=/gcs-data -port 4080
volumes:
- ./.jsr-data/gcs:/gcs-data
jsr-api:
depends_on:
jsr-db:
condition: service_healthy
jsr-gcs:
condition: service_started
healthcheck:
test: "curl --fail http://localhost:8001/sitemap.xml || exit 1"
interval: 5s
retries: 20
start_period: 5s
build:
context: .
dockerfile: Dockerfile.jsr
environment:
- "DATABASE_URL=postgres://user:password@jsr-db/registry"
- "GITHUB_CLIENT_ID=fake"
- "GITHUB_CLIENT_SECRET=fake"
- "GCS_ENDPOINT=http://jsr-gcs:4080"
- "MODULES_BUCKET=modules"
- "PUBLISHING_BUCKET=publishing"
- "DOCS_BUCKET=docs"
- "NPM_BUCKET=npm"
- "REGISTRY_URL=http://localhost:4873"
- "NPM_URL=http://example.com/unused"
jsr:
depends_on:
jsr-api:
condition: service_healthy
image: nginx:1.21
volumes:
- ./nginx.conf:/etc/nginx/nginx.conf
ports:
- "4873:80"
# our stuff
build:
build:
context: ../..
dockerfile: e2e/deno/Dockerfile.build
environment:
- GITHUB_TOKEN=${GITHUB_TOKEN}
depends_on:
- jsr
test:
build:
context: .
dockerfile: Dockerfile.test
environment:
- API_ID=${API_ID}
- API_HASH=${API_HASH}
depends_on:
- jsr
networks:
mtcute-e2e: {}

View file

@ -1,65 +0,0 @@
/* eslint-disable no-console */
import { execSync } from 'node:child_process'
function getDockerContainerIp(name) {
const containerId = execSync(`docker compose ps -q ${name}`).toString().trim()
const ip = execSync(`docker inspect -f '{{range .NetworkSettings.Networks}}{{.IPAddress}}{{end}}' ${containerId}`)
.toString()
.trim()
return ip
}
for (const stmt of [
"delete from tokens where user_id = '00000000-0000-0000-0000-000000000000';",
"insert into tokens (hash, user_id, type, expires_at) values ('3c469e9d6c5875d37a43f353d4f88e61fcf812c66eee3457465a40b0da4153e0', '00000000-0000-0000-0000-000000000000', 'web', current_date + interval '100' year);",
"update users set is_staff = true, scope_limit = 99999 where id = '00000000-0000-0000-0000-000000000000';",
]) {
execSync(`docker compose exec jsr-db psql registry -U user -c "${stmt}"`)
}
console.log('[i] Initialized database')
const GCS_URL = `http://${getDockerContainerIp('jsr-gcs')}:4080/`
const API_URL = `http://${getDockerContainerIp('jsr-api')}:8001/`
async function createBucket(name) {
try {
const resp = await fetch(`${GCS_URL}storage/v1/b`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ name }),
})
await resp.text()
return resp.ok || resp.status === 409
} catch (e) {
console.log(e)
return false
}
}
for (const bucket of ['modules', 'docs', 'publishing', 'npm']) {
const ok = await createBucket(bucket)
console.log(`[i] Created bucket ${bucket}: ${ok}`)
}
// create @mtcute scope if it doesn't exist
const resp = await fetch(`${API_URL}api/scopes`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'Cookie': 'token=token',
},
body: JSON.stringify({ scope: 'mtcute' }),
})
if (resp.status !== 200 && resp.status !== 409) {
throw new Error(`Failed to create scope: ${resp.statusText} ${await resp.text()}`)
}
if (resp.status === 200) {
console.log('[i] Created scope mtcute')
}

View file

@ -1,29 +0,0 @@
events {}
http {
upstream gcs {
server jsr-gcs:4080;
}
upstream api {
server jsr-api:8001;
}
error_log /error.log debug;
server {
listen 80;
location ~ ^/(@.*)$ {
proxy_pass http://gcs/storage/v1/b/modules/o/$1?alt=media;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
location / {
proxy_pass http://api;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
}

View file

@ -1,81 +0,0 @@
import { tl, User } from '@mtcute/core'
import { BaseTelegramClient, TelegramClient } from '@mtcute/core/client.js'
import { assertEquals } from 'https://deno.land/std@0.223.0/assert/mod.ts'
import { getApiParams } from '../utils.ts'
function getAccountId() {
return Math.floor(Math.random() * 10000)
.toString()
.padStart(4, '0')
}
async function authorizeInDc(dc: number, base: BaseTelegramClient) {
const tg = new TelegramClient({ client: base })
while (true) {
await base.mt.storage.load()
await base.storage.clear(true)
const phone = `99966${dc}${getAccountId()}`
let user
try {
const sentCode = await tg.sendCode({ phone })
let auth = await tg.call({
_: 'auth.signIn',
phoneNumber: phone,
phoneCode: `${dc}${dc}${dc}${dc}${dc}`,
phoneCodeHash: sentCode.phoneCodeHash,
})
if (auth._ === 'auth.authorizationSignUpRequired') {
auth = await tg.call({
_: 'auth.signUp',
phoneNumber: phone,
phoneCodeHash: sentCode.phoneCodeHash,
firstName: 'mtcute e2e',
lastName: '',
})
if (auth._ !== 'auth.authorization') {
throw new Error('Unexpected response')
}
}
await tg.notifyLoggedIn(auth)
user = new User(auth.user)
} catch (e) {
if (tl.RpcError.is(e, 'SESSION_PASSWORD_NEEDED') || tl.RpcError.is(e, 'PHONE_NUMBER_FLOOD')) {
// retry with another number
await tg.close()
continue
}
throw e
}
await tg.close()
assertEquals(user.isSelf, true)
assertEquals(user.phoneNumber, phone)
break
}
}
Deno.test('1. authorization', { sanitizeResources: false }, async (t) => {
await t.step('should authorize in default dc', async () => {
const base = new BaseTelegramClient(getApiParams('dc2.session'))
await authorizeInDc(2, base)
})
await t.step('should authorize in dc 1', async () => {
const base = new BaseTelegramClient(getApiParams('dc1.session'))
await authorizeInDc(1, base)
})
})

View file

@ -1,46 +0,0 @@
import { MtPeerNotFoundError } from '@mtcute/core'
import { TelegramClient } from '@mtcute/core/client.js'
import { assertEquals } from 'https://deno.land/std@0.223.0/assert/mod.ts'
import { getApiParams } from '../utils.ts'
Deno.test('2. calling methods', { sanitizeResources: false }, async (t) => {
const tg = new TelegramClient(getApiParams('dc2.session'))
await tg.connect()
await t.step('getUsers(@BotFather)', async () => {
const [user] = await tg.getUsers('botfather')
assertEquals(user?.isBot, true)
assertEquals(user?.displayName, 'BotFather')
})
await t.step('getUsers(@BotFather) - cached', async () => {
const [user] = await tg.getUsers('botfather')
assertEquals(user?.isBot, true)
assertEquals(user?.displayName, 'BotFather')
})
await t.step('getHistory(777000)', async () => {
try {
await tg.findDialogs(777000) // ensure it's cached
} catch (e) {
if (e instanceof MtPeerNotFoundError) {
// this happens sometimes :D gracefully skip
return
}
throw e
}
const history = await tg.getHistory(777000, { limit: 5 })
assertEquals(history[0].chat.chatType, 'private')
assertEquals(history[0].chat.id, 777000)
assertEquals(history[0].chat.firstName, 'Telegram')
})
await tg.close()
})

View file

@ -1,171 +0,0 @@
import type { FileDownloadLocation } from '@mtcute/core'
import { createHash } from 'node:crypto'
import { Thumbnail } from '@mtcute/core'
import { TelegramClient } from '@mtcute/core/client.js'
import { sleep } from '@mtcute/core/utils.js'
import { assertEquals } from 'https://deno.land/std@0.223.0/assert/mod.ts'
import { getApiParams } from '../utils.ts'
const CINNAMOROLL_PFP_CHAT = 'test_file_dc2'
const CINNAMOROLL_PFP_THUMB_SHA256 = '3e6f220235a12547c16129f50c19ed3224d39b827414d1d500f79569a3431eae'
const CINNAMOROLL_PFP_SHA256 = '4d9836a71ac039f5656cde55b83525871549bfbff9cfb658c3f8381c5ba89ce8'
const UWU_MSG = 'https://t.me/test_file_dc2/8'
const UWU_SHA256 = '357b78c9f9d20e813f729a19dd90c6727f30ebd4c8c83557022285f283a705b9'
const SHREK_MSG = 'https://t.me/test_file_dc2/11'
const SHREK_SHA256 = 'd3e6434e027f3d31dc3e05c6ea2eaf84fdd1fb00774a215f89d9ed8b56f86258'
const LARGE_MSG = 'https://t.me/test_file_dc2/12'
async function downloadAsSha256(client: TelegramClient, location: FileDownloadLocation): Promise<string> {
const sha = createHash('sha256')
for await (const chunk of client.downloadAsIterable(location)) {
sha.update(chunk)
}
return sha.digest('hex')
}
Deno.test('3. working with files', { sanitizeResources: false }, async (t) => {
// sometimes test dcs are overloaded and we get FILE_REFERENCE_EXPIRED
// because we got multiple -500:No workers running errors in a row
// we currently don't have file references database, so we can just retry the test for now
//
// ...except we can't under deno because it's not implemented
// https://github.com/denoland/deno/issues/19882
// this.retries(2)
await t.step('same-dc', async (t) => {
const tg = new TelegramClient(getApiParams('dc2.session'))
await tg.connect()
await t.step('should download pfp thumbs', async () => {
const chat = await tg.getChat(CINNAMOROLL_PFP_CHAT)
if (!chat.photo) throw new Error('Chat has no photo')
assertEquals(await downloadAsSha256(tg, chat.photo.big), CINNAMOROLL_PFP_THUMB_SHA256)
})
await t.step('should download animated pfps', async () => {
const chat = await tg.getFullChat(CINNAMOROLL_PFP_CHAT)
const thumb = chat.fullPhoto?.getThumbnail(Thumbnail.THUMB_VIDEO_PROFILE)
if (!thumb) throw new Error('Chat has no animated pfp')
assertEquals(await downloadAsSha256(tg, thumb), CINNAMOROLL_PFP_SHA256)
})
await t.step('should download photos', async () => {
const msg = await tg.getMessageByLink(UWU_MSG)
if (msg?.media?.type !== 'photo') {
throw new Error('Message not found or not a photo')
}
assertEquals(await downloadAsSha256(tg, msg.media), UWU_SHA256)
})
await t.step('should download documents', async () => {
const msg = await tg.getMessageByLink(SHREK_MSG)
if (msg?.media?.type !== 'document') {
throw new Error('Message not found or not a document')
}
assertEquals(await downloadAsSha256(tg, msg.media), SHREK_SHA256)
})
await t.step('should cancel downloads', async () => {
const msg = await tg.getMessageByLink(LARGE_MSG)
if (msg?.media?.type !== 'document') {
throw new Error('Message not found or not a document')
}
const media = msg.media
const abort = new AbortController()
let downloaded = 0
async function download() {
const dl = tg.downloadAsIterable(media, { abortSignal: abort.signal })
try {
for await (const chunk of dl) {
downloaded += chunk.length
}
} catch (e) {
if (!(e instanceof DOMException && e.name === 'AbortError')) throw e
}
}
const promise = download()
// let it download for 10 seconds
await sleep(10000)
abort.abort()
// abort and snap the downloaded amount
const downloadedBefore = downloaded
const avgSpeed = downloaded / 10
// eslint-disable-next-line no-console
console.log('Average speed: %d KiB/s', avgSpeed / 1024)
// wait a bit more to make sure it's aborted
await sleep(2000)
await promise
assertEquals(downloaded, downloadedBefore, 'nothing should be downloaded after abort')
})
await tg.close()
})
await t.step('cross-dc', async (t) => {
const tg = new TelegramClient(getApiParams('dc1.session'))
await tg.connect()
await t.step('should download pfp thumbs', async () => {
const chat = await tg.getChat(CINNAMOROLL_PFP_CHAT)
if (!chat.photo) throw new Error('Chat has no photo')
assertEquals(await downloadAsSha256(tg, chat.photo.big), CINNAMOROLL_PFP_THUMB_SHA256)
})
await t.step('should download animated pfps', async () => {
const chat = await tg.getFullChat(CINNAMOROLL_PFP_CHAT)
const thumb = chat.fullPhoto?.getThumbnail(Thumbnail.THUMB_VIDEO_PROFILE)
if (!thumb) throw new Error('Chat has no animated pfp')
assertEquals(await downloadAsSha256(tg, thumb), CINNAMOROLL_PFP_SHA256)
})
await t.step('should download photos', async () => {
const msg = await tg.getMessageByLink(UWU_MSG)
if (msg?.media?.type !== 'photo') {
throw new Error('Message not found or not a photo')
}
assertEquals(await downloadAsSha256(tg, msg.media), UWU_SHA256)
})
await t.step('should download documents', async () => {
const msg = await tg.getMessageByLink(SHREK_MSG)
if (msg?.media?.type !== 'document') {
throw new Error('Message not found or not a document')
}
assertEquals(await downloadAsSha256(tg, msg.media), SHREK_SHA256)
})
await tg.close()
})
})

View file

@ -1,46 +0,0 @@
import type { Message } from '@mtcute/core'
import { TelegramClient } from '@mtcute/core/client.js'
import { assertEquals, assertNotEquals } from 'https://deno.land/std@0.223.0/assert/mod.ts'
import { getApiParams, waitFor } from '../utils.ts'
Deno.test('4. handling updates', { sanitizeResources: false }, async (t) => {
const tg1 = new TelegramClient(getApiParams('dc1.session'))
tg1.log.prefix = '[tg1] '
const tg2 = new TelegramClient(getApiParams('dc2.session'))
tg2.log.prefix = '[tg2] '
await tg1.connect()
await tg1.startUpdatesLoop()
await tg2.connect()
await t.step('should send and receive messages', async () => {
const tg1Messages: Message[] = []
tg1.on('new_message', msg => tg1Messages.push(msg))
const [tg1User] = await tg1.getUsers('self')
let username = tg1User!.username
if (!username) {
username = `mtcute_e2e_${Math.random().toString(36).slice(2)}`
await tg1.setMyUsername(username)
}
const messageText = `mtcute test message ${Math.random().toString(36).slice(2)}`
const sentMsg = await tg2.sendText(username, messageText)
assertEquals(sentMsg.text, messageText)
assertEquals(sentMsg.chat.id, tg1User!.id)
await waitFor(() => {
assertNotEquals(
tg1Messages.find(msg => msg.text === messageText),
undefined,
)
})
})
await tg1.close()
await tg2.close()
})

View file

@ -1,85 +0,0 @@
import type { Message } from '@mtcute/deno'
import type { CustomMethods } from './_worker.ts'
import { TelegramClient } from '@mtcute/core/client.js'
import { Long, TelegramWorkerPort, tl } from '@mtcute/deno'
import { assertEquals, assertGreater, assertInstanceOf } from 'https://deno.land/std@0.223.0/assert/mod.ts'
import { getApiParams, waitFor } from '../utils.ts'
Deno.test('5. worker', { sanitizeResources: false }, async (t) => {
const worker = new Worker(new URL('_worker.ts', import.meta.url), {
type: 'module',
})
const port = new TelegramWorkerPort<CustomMethods>({
worker,
})
const portClient = new TelegramClient({ client: port })
await t.step('should make api calls', async () => {
const res = await port.call({ _: 'help.getConfig' })
assertEquals(res._, 'config')
const premiumPromo = await port.call({ _: 'help.getPremiumPromo' })
// ensure Long-s are correctly serialized
assertEquals(Long.isLong((premiumPromo.users[0] as tl.RawUser).accessHash), true)
})
await t.step('should call custom methods', async () => {
const hello = await port.invokeCustom('hello')
assertEquals(hello, 'world')
const sum = await port.invokeCustom('sum', 2, 3)
assertEquals(sum, 5)
})
await t.step('should throw errors', async () => {
try {
await port.call({ _: 'test.useConfigSimple' })
throw new Error('should have thrown')
} catch (e) {
assertInstanceOf(e, tl.RpcError)
}
})
await t.step('should receive updates', async () => {
const client2 = new TelegramClient(getApiParams('dc2.session'))
try {
await client2.connect()
await port.startUpdatesLoop()
const me = await portClient.getMe()
// ensure Long-s are correctly serialized
assertEquals(Long.isLong(me.raw.accessHash), true)
let username = me.username
if (!username) {
username = `mtcute_e2e_${Math.random().toString(36).slice(2, 8)}`
await portClient.setMyUsername(username)
}
const msgs: Message[] = []
portClient.on('new_message', (msg) => {
msgs.push(msg)
})
const testText = `test ${Math.random()}`
await client2.sendText(username, testText)
await waitFor(() => {
assertGreater(msgs.length, 0)
assertEquals(msgs[0].text, testText)
})
} catch (e) {
await client2.close()
throw e
}
await client2.close()
})
await port.close()
worker.terminate()
})

View file

@ -1,18 +0,0 @@
import type { WorkerCustomMethods } from '@mtcute/core/worker.js'
import { BaseTelegramClient, TelegramWorker } from '@mtcute/deno'
import { getApiParams } from '../utils.ts'
const customMethods = {
hello: async () => 'world',
sum: async (a: number, b: number) => a + b,
} as const satisfies WorkerCustomMethods
export type CustomMethods = typeof customMethods
const client = new BaseTelegramClient(getApiParams('dc1.session'))
// eslint-disable-next-line no-new
new TelegramWorker({
client,
customMethods,
})

View file

@ -1,20 +0,0 @@
import { BaseTelegramClient } from '@mtcute/core/client.js'
import { assertEquals } from 'https://deno.land/std@0.223.0/assert/mod.ts'
import { getApiParams } from '../../utils.ts'
Deno.test('@mtcute/core', async (t) => {
await t.step('connects to test DC and makes help.getNearestDc', async () => {
const tg = new BaseTelegramClient({
...getApiParams(),
})
await tg.connect()
const config = await tg.call({ _: 'help.getNearestDc' })
await tg.close()
assertEquals(typeof config, 'object')
assertEquals(config._, 'nearestDc')
assertEquals(config.thisDc, 2)
})
})

View file

@ -1,71 +0,0 @@
import { Long } from '@mtcute/core'
import { setPlatform } from '@mtcute/core/platform.js'
import { TlBinaryReader, TlBinaryWriter, TlSerializationCounter } from '@mtcute/tl-runtime'
import { WebPlatform } from '@mtcute/web'
import { assertEquals } from 'https://deno.land/std@0.223.0/assert/mod.ts'
// here we primarily want to check that everything imports properly,
// and that the code is actually executable. The actual correctness
// of the implementation is covered tested by unit tests
const p = new WebPlatform()
setPlatform(p)
Deno.test('encodings', () => {
assertEquals(p.hexEncode(new Uint8Array([1, 2, 3, 4, 5])), '0102030405')
})
Deno.test('TlBinaryReader', () => {
const map = {
85337187(r: any) {
const ret: any = {}
ret._ = 'mt_resPQ'
ret.nonce = r.int128()
ret.serverNonce = r.int128()
ret.pq = r.bytes()
ret.serverPublicKeyFingerprints = r.vector(r.long)
return ret
},
}
const data
= '000000000000000001c8831ec97ae55140000000632416053e0549828cca27e966b301a48fece2fca5cf4d33f4a11ea877ba4aa5739073300817ed48941a08f98100000015c4b51c01000000216be86c022bb4c3'
const buf = p.hexDecode(data)
const r = new TlBinaryReader(map, buf, 8)
assertEquals(r.long().toString(16), '51e57ac91e83c801')
assertEquals(r.uint(), 64)
const obj: any = r.object()
assertEquals(obj._, 'mt_resPQ')
})
Deno.test('TlBinaryWriter', () => {
const map = {
mt_resPQ(w: any, obj: any) {
w.uint(85337187)
w.bytes(obj.pq)
w.vector(w.long, obj.serverPublicKeyFingerprints)
},
_staticSize: {} as any,
}
const obj = {
_: 'mt_resPQ',
pq: p.hexDecode('17ED48941A08F981'),
serverPublicKeyFingerprints: [Long.fromString('c3b42b026ce86b21', 16)],
}
assertEquals(TlSerializationCounter.countNeededBytes(map, obj), 32)
const w = TlBinaryWriter.alloc(map, 48)
w.long(Long.ZERO)
w.long(Long.fromString('51E57AC91E83C801', true, 16)) // messageId
w.object(obj)
assertEquals(
p.hexEncode(w.result()),
'000000000000000001c8831ec97ae551632416050817ed48941a08f98100000015c4b51c01000000216be86c022bb4c3',
)
})

View file

@ -1,42 +0,0 @@
import { Long } from '@mtcute/core'
import { setPlatform } from '@mtcute/core/platform.js'
import { tl } from '@mtcute/tl'
import { TlBinaryReader, TlBinaryWriter } from '@mtcute/tl-runtime'
import { __tlReaderMap } from '@mtcute/tl/binary/reader.js'
import { __tlWriterMap } from '@mtcute/tl/binary/writer.js'
import { WebPlatform } from '@mtcute/web'
import { assertEquals } from 'https://deno.land/std@0.223.0/assert/mod.ts'
// here we primarily want to check that @mtcute/tl correctly works with @mtcute/tl-runtime
const p = new WebPlatform()
setPlatform(p)
Deno.test('@mtcute/tl', async (t) => {
await t.step('writers map works with TlBinaryWriter', () => {
const obj = {
_: 'inputPeerUser',
userId: 123,
accessHash: Long.fromNumber(456),
}
assertEquals(
p.hexEncode(TlBinaryWriter.serializeObject(__tlWriterMap, obj)),
'4ca5e8dd7b00000000000000c801000000000000',
)
})
await t.step('readers map works with TlBinaryReader', () => {
const buf = p.hexDecode('4ca5e8dd7b00000000000000c801000000000000')
const obj = TlBinaryReader.deserializeObject<any>(__tlReaderMap, buf)
assertEquals(obj._, 'inputPeerUser')
assertEquals(obj.userId, 123)
assertEquals(obj.accessHash.toString(), '456')
})
await t.step('correctly checks for combinator types', () => {
assertEquals(tl.isAnyInputUser({ _: 'inputUserEmpty' }), true)
})
})

View file

@ -1,24 +0,0 @@
import { ige256Decrypt, ige256Encrypt } from '@mtcute/wasm'
import { WebCryptoProvider, WebPlatform } from '@mtcute/web'
import { assertEquals } from 'https://deno.land/std@0.223.0/assert/mod.ts'
await new WebCryptoProvider().initialize()
const platform = new WebPlatform()
Deno.test('@mtcute/wasm', async (t) => {
const key = platform.hexDecode('5468697320697320616E20696D706C655468697320697320616E20696D706C65')
const iv = platform.hexDecode('6D656E746174696F6E206F6620494745206D6F646520666F72204F70656E5353')
const data = platform.hexDecode('99706487a1cde613bc6de0b6f24b1c7aa448c8b9c3403e3467a8cad89340f53b')
const dataEnc = platform.hexDecode('792ea8ae577b1a66cb3bd92679b8030ca54ee631976bd3a04547fdcb4639fa69')
await t.step('should work with Buffers', () => {
assertEquals(ige256Encrypt(data, key, iv), dataEnc)
assertEquals(ige256Decrypt(dataEnc, key, iv), data)
})
await t.step('should work with Uint8Arrays', () => {
assertEquals(ige256Encrypt(data, key, iv), dataEnc)
assertEquals(ige256Decrypt(dataEnc, key, iv), data)
})
})

View file

@ -1,43 +0,0 @@
import type { MaybePromise } from '@mtcute/core'
import { MemoryStorage } from '@mtcute/core'
import { setPlatform } from '@mtcute/core/platform.js'
import { LogManager, sleep } from '@mtcute/core/utils.js'
import { DenoCryptoProvider, DenoPlatform, SqliteStorage, TcpTransport } from '@mtcute/deno'
export function getApiParams(storage?: string) {
if (!Deno.env.has('API_ID') || !Deno.env.has('API_HASH')) {
throw new Error('API_ID and API_HASH env variables must be set')
}
Deno.mkdirSync('.sessions', { recursive: true })
setPlatform(new DenoPlatform())
return {
apiId: Number.parseInt(Deno.env.get('API_ID')!),
apiHash: Deno.env.get('API_HASH')!,
testMode: true,
storage: storage ? new SqliteStorage(`.sessions/${storage}`) : new MemoryStorage(),
logLevel: LogManager.VERBOSE,
transport: () => new TcpTransport(),
crypto: new DenoCryptoProvider(),
}
}
export async function waitFor(condition: () => MaybePromise<void>, timeout = 5000): Promise<void> {
const start = Date.now()
let lastError
while (Date.now() - start < timeout) {
try {
await condition()
return
} catch (e) {
lastError = e
await sleep(100)
}
}
throw lastError
}

23
e2e/import-map.json Normal file
View file

@ -0,0 +1,23 @@
{
"imports": {
"@fuman/utils": "jsr:@fuman/utils@0.0.1",
"chai": "https://esm.sh/chai@5.1.2",
"node:test": "./tests/deno-shims/node-test.js",
"mtcute": "../packages/deno/src/index.ts",
"mtcute/utils.js": "../packages/deno/src/utils.ts",
"@mtcute/core": "../packages/core/src/index.ts",
"@mtcute/core/client.js": "../packages/core/src/highlevel/client.ts",
"@mtcute/core/methods.js": "../packages/core/src/highlevel/methods.ts",
"@mtcute/core/utils.js": "../packages/core/src/utils/index.ts",
"@mtcute/core/worker.js": "../packages/core/src/highlevel/worker/index.ts",
"@mtcute/markdown-parser": "../packages/markdown-parser/src/index.ts",
"@mtcute/html-parser": "../packages/html-parser/src/index.ts",
"@mtcute/file-id": "../packages/file-id/src/index.ts",
"@mtcute/tl-runtime": "../packages/tl-runtime/src/index.ts",
"@mtcute/wasm": "../packages/wasm/src/index.ts",
"@mtcute/tl": "./tests/deno-shims/tl.js",
"@mtcute/tl/binary/rsa-keys.js": "./tests/deno-shims/tl-rsa.js",
"@mtcute/tl/binary/reader.js": "./tests/deno-shims/tl-reader.js",
"@mtcute/tl/binary/writer.js": "./tests/deno-shims/tl-writer.js"
}
}

View file

@ -1,7 +0,0 @@
.verdaccio
node_modules
private
dist
pnpm-lock.yaml
.npmrc
.env*

View file

@ -1,5 +0,0 @@
# obtain these values from my.telegram.org
API_ID=
API_HASH=
GITHUB_TOKEN=

4
e2e/node/.gitignore vendored
View file

@ -1,4 +0,0 @@
.verdaccio/storage
.npmrc
pnpm-lock.yaml
.env

View file

@ -1,3 +0,0 @@
{
"node-option": []
}

View file

@ -1,13 +0,0 @@
storage: ./storage
auth:
htpasswd:
file: ./htpasswd
max_users: -1
packages:
'**':
access: $all
publish: $all
logs: { type: stdout, format: pretty, level: trace }

View file

@ -1 +0,0 @@
mtcute-bot:$apr1$7rbqxva0$zyfFgknsbAxni.cq158Sf.

View file

@ -1,20 +0,0 @@
FROM node:20-alpine
WORKDIR /app
RUN apk add python3 py3-pip make g++ && \
python3 -m pip install --break-system-packages setuptools && \
corepack enable && \
corepack prepare pnpm@9.0.6 --activate
COPY ../.. /app/
RUN pnpm install --frozen-lockfile && \
pnpm -C packages/tl run gen-code && \
# verdaccio is configured to allow anonymous publish, but npm requires a token 🥴
npm config set //verdaccio:4873/:_authToken fake-token
ENV REGISTRY="http://verdaccio:4873/"
ENV E2E="1"
ENTRYPOINT [ "node", "/app/scripts/publish.js" ]
CMD [ "all" ]

View file

@ -1,14 +0,0 @@
FROM node:20-alpine
WORKDIR /app
RUN apk add python3 py3-pip make g++ && \
python3 -m pip install --break-system-packages setuptools && \
corepack enable && \
corepack prepare pnpm@9.0.6 --activate
COPY ./ /app/
RUN npm config set -L project @mtcute:registry http://verdaccio:4873/ && \
chmod +x ./docker-entrypoint.sh
ENTRYPOINT [ "./docker-entrypoint.sh" ]
CMD [ "all" ]

View file

@ -1,48 +0,0 @@
# mtcute e2e tests
This directory contains end-to-end tests for mtcute.
They are made for 2 purposes:
- Ensure published packages work as expected and can properly be imported
- Ensure that the library works with the actual Telegram API (WIP)
To achieve the first goal, we use a Verdaccio container to publish the package to,
and then install it from there in another container
## Setting up
Before running the tests, you need to copy `.env.example` to `.env` and fill in the values
## Running tests
To run tests, you need to have Docker installed.
```bash
# first start Verdaccio:
./cli.sh start
# build and publish the package
./cli.sh update
# or a particular package
./cli.sh update tl-runtime
# run the tests
./cli.sh run
# or in docker
./cli.sh run-docker
```
## Developing
Once you have Verdaccio running, you can run the following commands to setup
the environment for development:
```bash
npm config set -L project @mtcute:registry http://verdaccio.e2e.orb.local/
./cli.sh install
```
> Replace the URL above with the one generated with your Docker GUI of choice
> (e2e > verdaccio > RMB > Open in browser). Example above assumes OrbStack
Then use `./cli.sh run` to run the tests

View file

@ -1 +0,0 @@
{}

View file

@ -1,23 +0,0 @@
const { BaseTelegramClient } = require('@mtcute/core/client.js')
const { expect } = require('chai')
const { describe, it } = require('mocha')
const { getApiParams } = require('../utils')
describe('@mtcute/core', function () {
this.timeout(300_000)
it('connects to test DC and makes help.getNearestDc', async () => {
const tg = new BaseTelegramClient({
...getApiParams(),
})
await tg.connect()
const config = await tg.call({ _: 'help.getNearestDc' })
await tg.close()
expect(config).to.be.an('object')
expect(config._).to.equal('nearestDc')
expect(config.thisDc).to.equal(2)
})
})

View file

@ -1,112 +0,0 @@
const { NodePlatform } = require('@mtcute/node')
const { TlBinaryReader, TlBinaryWriter, TlSerializationCounter } = require('@mtcute/tl-runtime')
const { expect } = require('chai')
const Long = require('long')
const { describe, it } = require('mocha')
// here we primarily want to check that everything imports properly,
// and that the code is actually executable. The actual correctness
// of the implementation is covered tested by unit tests
const p = new NodePlatform()
describe('@mtcute/tl-runtime', () => {
describe('encodings', () => {
it('works with Buffers', () => {
expect(p.hexEncode(Buffer.from('hello'))).to.equal('68656c6c6f')
expect(p.hexDecode('0102030405')).eql(Buffer.from([1, 2, 3, 4, 5]))
})
it('works with Uint8Arrays', () => {
expect(p.hexEncode(new Uint8Array([1, 2, 3, 4, 5]))).to.equal('0102030405')
})
})
describe('TlBinaryReader', () => {
const map = {
85337187(r) {
const ret = {}
ret._ = 'mt_resPQ'
ret.nonce = r.int128()
ret.serverNonce = r.int128()
ret.pq = r.bytes()
ret.serverPublicKeyFingerprints = r.vector(r.long)
return ret
},
}
const data
= '000000000000000001c8831ec97ae55140000000632416053e0549828cca27e966b301a48fece2fca5cf4d33f4a11ea877ba4aa5739073300817ed48941a08f98100000015c4b51c01000000216be86c022bb4c3'
it('should work with Buffers', () => {
const buf = Buffer.from(data, 'hex')
const r = new TlBinaryReader(map, buf, 8)
expect(r.long().toString(16)).to.equal('51e57ac91e83c801')
expect(r.uint()).to.equal(64)
const obj = r.object()
expect(obj._).equal('mt_resPQ')
})
it('should work with Uint8Arrays', () => {
const buf = p.hexDecode(data)
const r = new TlBinaryReader(map, buf, 8)
expect(r.long().toString(16)).to.equal('51e57ac91e83c801')
expect(r.uint()).to.equal(64)
const obj = r.object()
expect(obj._).equal('mt_resPQ')
})
})
describe('TlBinaryWriter', () => {
const map = {
mt_resPQ(w, obj) {
w.uint(85337187)
w.bytes(obj.pq)
w.vector(w.long, obj.serverPublicKeyFingerprints)
},
}
it('should work with Buffers', () => {
const obj = {
_: 'mt_resPQ',
pq: Buffer.from('17ED48941A08F981', 'hex'),
serverPublicKeyFingerprints: [Long.fromString('c3b42b026ce86b21', 16)],
}
expect(TlSerializationCounter.countNeededBytes(map, obj)).to.equal(32)
const w = TlBinaryWriter.alloc(map, 48)
w.long(Long.ZERO)
w.long(Long.fromString('51E57AC91E83C801', true, 16)) // messageId
w.object(obj)
expect(p.hexEncode(w.result())).eq(
'000000000000000001c8831ec97ae551632416050817ed48941a08f98100000015c4b51c01000000216be86c022bb4c3',
)
})
it('should work with Uint8Arrays', () => {
const obj = {
_: 'mt_resPQ',
pq: p.hexDecode('17ED48941A08F981'),
serverPublicKeyFingerprints: [Long.fromString('c3b42b026ce86b21', 16)],
}
expect(TlSerializationCounter.countNeededBytes(map, obj)).to.equal(32)
const w = TlBinaryWriter.alloc(map, 48)
w.long(Long.ZERO)
w.long(Long.fromString('51E57AC91E83C801', true, 16)) // messageId
w.object(obj)
expect(p.hexEncode(w.result())).eq(
'000000000000000001c8831ec97ae551632416050817ed48941a08f98100000015c4b51c01000000216be86c022bb4c3',
)
})
})
})

View file

@ -1,39 +0,0 @@
const { NodePlatform } = require('@mtcute/node')
const { tl } = require('@mtcute/tl')
const { TlBinaryReader, TlBinaryWriter } = require('@mtcute/tl-runtime')
const { __tlReaderMap } = require('@mtcute/tl/binary/reader')
const { __tlWriterMap } = require('@mtcute/tl/binary/writer')
const { expect } = require('chai')
const Long = require('long')
const { describe, it } = require('mocha')
// here we primarily want to check that @mtcute/tl correctly works with @mtcute/tl-runtime
const p = new NodePlatform()
describe('@mtcute/tl', () => {
it('writers map works with TlBinaryWriter', () => {
const obj = {
_: 'inputPeerUser',
userId: 123,
accessHash: Long.fromNumber(456),
}
expect(p.hexEncode(TlBinaryWriter.serializeObject(__tlWriterMap, obj))).to.equal(
'4ca5e8dd7b00000000000000c801000000000000',
)
})
it('readers map works with TlBinaryReader', () => {
const buf = Buffer.from('4ca5e8dd7b00000000000000c801000000000000', 'hex')
const obj = TlBinaryReader.deserializeObject(__tlReaderMap, buf)
expect(obj._).equal('inputPeerUser')
expect(obj.userId).equal(123)
expect(obj.accessHash.toString()).equal('456')
})
it('correctly checks for combinator types', () => {
expect(tl.isAnyInputUser({ _: 'inputUserEmpty' })).to.eq(true)
})
})

View file

@ -1,30 +0,0 @@
const { NodeCryptoProvider } = require('@mtcute/node/utils.js')
const wasm = require('@mtcute/wasm')
const { expect } = require('chai')
const { describe, it, before } = require('mocha')
before(async () => {
await new NodeCryptoProvider().initialize()
})
describe('@mtcute/wasm', () => {
const key = Buffer.from('5468697320697320616E20696D706C655468697320697320616E20696D706C65', 'hex')
const iv = Buffer.from('6D656E746174696F6E206F6620494745206D6F646520666F72204F70656E5353', 'hex')
const data = Buffer.from('99706487a1cde613bc6de0b6f24b1c7aa448c8b9c3403e3467a8cad89340f53b', 'hex')
const dataEnc = Buffer.from('792ea8ae577b1a66cb3bd92679b8030ca54ee631976bd3a04547fdcb4639fa69', 'hex')
it('should work with Buffers', () => {
expect(wasm.ige256Encrypt(data, key, iv)).to.deep.equal(new Uint8Array(dataEnc))
expect(wasm.ige256Decrypt(dataEnc, key, iv)).to.deep.equal(new Uint8Array(data))
})
it('should work with Uint8Arrays', () => {
expect(wasm.ige256Encrypt(new Uint8Array(data), new Uint8Array(key), new Uint8Array(iv))).to.deep.equal(
new Uint8Array(dataEnc),
)
expect(wasm.ige256Decrypt(new Uint8Array(dataEnc), new Uint8Array(key), new Uint8Array(iv))).to.deep.equal(
new Uint8Array(data),
)
})
})

View file

@ -1,23 +0,0 @@
const { MemoryStorage } = require('@mtcute/core')
const { setPlatform } = require('@mtcute/core/platform.js')
const { LogManager } = require('@mtcute/core/utils.js')
const { NodePlatform, TcpTransport } = require('@mtcute/node')
const { NodeCryptoProvider } = require('@mtcute/node/utils.js')
exports.getApiParams = () => {
if (!process.env.API_ID || !process.env.API_HASH) {
throw new Error('API_ID and API_HASH env variables must be set')
}
setPlatform(new NodePlatform())
return {
apiId: Number.parseInt(process.env.API_ID),
apiHash: process.env.API_HASH,
testMode: true,
storage: new MemoryStorage(),
logLevel: LogManager.DEBUG,
transport: () => new TcpTransport(),
crypto: new NodeCryptoProvider(),
}
}

View file

@ -1,45 +0,0 @@
#!/bin/bash
set -eau
method=$1
shift
case "$method" in
"run")
node runner.js $@
;;
"run-docker")
source .env
docker compose run --rm --build test $@
;;
"update")
docker compose run --build build $@
./cli.sh install
;;
"start")
docker compose up -d verdaccio
;;
"stop")
docker compose down
;;
"install")
rm -rf pnpm-lock.yaml node_modules
pnpm install
;;
"ci")
set -eaux
chmod -R 777 .verdaccio
docker compose up -d verdaccio
docker compose run --rm --build build
docker compose run --build test
;;
"ci-publish")
export CURRENT_COMMIT=$(git rev-parse HEAD)
docker compose up -d verdaccio
node publish-canary.js
;;
*)
echo "Unknown command"
;;
esac

View file

@ -1,30 +0,0 @@
module.exports = {
cjs: {
getFiles: () => 'tests/**/*.js',
runFile: file => `mocha ${file}`,
},
esm: {
getFiles: () => 'tests/**/*.js',
runFile: file => `mocha ${file}`,
},
ts: {
getFiles: () => 'tests/**/*.ts',
beforeAll: () => ['tsc', 'node build-esm.cjs'],
runFile: (file) => {
if (require('node:path').basename(file)[0] === '_') return null
if (file.startsWith('tests/packaging/')) {
// packaging tests - we need to make sure everything imports and works
return [
`mocha -r ts-node/register ${file}`,
`mocha dist/${file.replace(/\.ts$/, '.js')}`,
`node run-esm.cjs ${file}`,
`mocha dist/esm/${file.replace(/\.ts$/, '.js')}`,
]
}
// normal e2e tests - testing features etc
return `mocha dist/${file.replace(/\.ts$/, '.js')}`
},
},
}

View file

@ -1,35 +0,0 @@
version: "3"
services:
verdaccio:
restart: unless-stopped
image: verdaccio/verdaccio:5.27
container_name: "verdaccio"
volumes:
- "./.verdaccio:/verdaccio/conf"
ports:
- "4873:4873"
networks:
- mtcute-e2e
build:
build:
context: ../..
dockerfile: e2e/node/Dockerfile.build
environment:
- GITHUB_TOKEN=${GITHUB_TOKEN}
networks:
- mtcute-e2e
depends_on:
- verdaccio
test:
build:
context: .
dockerfile: Dockerfile.test
environment:
- API_ID=${API_ID}
- API_HASH=${API_HASH}
networks:
- mtcute-e2e
depends_on:
- verdaccio
networks:
mtcute-e2e: {}

View file

@ -1,8 +0,0 @@
#!/bin/sh
set -e
# we can't do this during build because we don't have network access
pnpm install
node runner.js $@

View file

@ -1 +0,0 @@
{ "type": "module" }

View file

@ -1,23 +0,0 @@
import { BaseTelegramClient } from '@mtcute/core/client.js'
import { expect } from 'chai'
import { describe, it } from 'mocha'
import { getApiParams } from '../utils.js'
describe('@mtcute/core', function () {
this.timeout(300_000)
it('connects to test DC and makes help.getNearestDc', async () => {
const tg = new BaseTelegramClient({
...getApiParams(),
})
await tg.connect()
const config = await tg.call({ _: 'help.getNearestDc' })
await tg.close()
expect(config).to.be.an('object')
expect(config._).to.equal('nearestDc')
expect(config.thisDc).to.equal(2)
})
})

View file

@ -1,110 +0,0 @@
import { NodePlatform } from '@mtcute/node'
import { TlBinaryReader, TlBinaryWriter, TlSerializationCounter } from '@mtcute/tl-runtime'
import { expect } from 'chai'
import Long from 'long'
import { describe, it } from 'mocha'
// here we primarily want to check that everything imports properly,
// and that the code is actually executable. The actual correctness
// of the implementation is covered tested by unit tests
const p = new NodePlatform()
describe('encodings', () => {
it('works with Buffers', () => {
expect(p.hexEncode(Buffer.from('hello'))).to.equal('68656c6c6f')
expect(p.hexDecode('0102030405')).eql(Buffer.from([1, 2, 3, 4, 5]))
})
it('works with Uint8Arrays', () => {
expect(p.hexEncode(new Uint8Array([1, 2, 3, 4, 5]))).to.equal('0102030405')
})
})
describe('TlBinaryReader', () => {
const map = {
85337187(r) {
const ret = {}
ret._ = 'mt_resPQ'
ret.nonce = r.int128()
ret.serverNonce = r.int128()
ret.pq = r.bytes()
ret.serverPublicKeyFingerprints = r.vector(r.long)
return ret
},
}
const data
= '000000000000000001c8831ec97ae55140000000632416053e0549828cca27e966b301a48fece2fca5cf4d33f4a11ea877ba4aa5739073300817ed48941a08f98100000015c4b51c01000000216be86c022bb4c3'
it('should work with Buffers', () => {
const buf = Buffer.from(data, 'hex')
const r = new TlBinaryReader(map, buf, 8)
expect(r.long().toString(16)).to.equal('51e57ac91e83c801')
expect(r.uint()).to.equal(64)
const obj = r.object()
expect(obj._).equal('mt_resPQ')
})
it('should work with Uint8Arrays', () => {
const buf = p.hexDecode(data)
const r = new TlBinaryReader(map, buf, 8)
expect(r.long().toString(16)).to.equal('51e57ac91e83c801')
expect(r.uint()).to.equal(64)
const obj = r.object()
expect(obj._).equal('mt_resPQ')
})
})
describe('TlBinaryWriter', () => {
const map = {
mt_resPQ(w, obj) {
w.uint(85337187)
w.bytes(obj.pq)
w.vector(w.long, obj.serverPublicKeyFingerprints)
},
}
it('should work with Buffers', () => {
const obj = {
_: 'mt_resPQ',
pq: Buffer.from('17ED48941A08F981', 'hex'),
serverPublicKeyFingerprints: [Long.fromString('c3b42b026ce86b21', 16)],
}
expect(TlSerializationCounter.countNeededBytes(map, obj)).to.equal(32)
const w = TlBinaryWriter.alloc(map, 48)
w.long(Long.ZERO)
w.long(Long.fromString('51E57AC91E83C801', true, 16)) // messageId
w.object(obj)
expect(p.hexEncode(w.result())).eq(
'000000000000000001c8831ec97ae551632416050817ed48941a08f98100000015c4b51c01000000216be86c022bb4c3',
)
})
it('should work with Uint8Arrays', () => {
const obj = {
_: 'mt_resPQ',
pq: p.hexDecode('17ED48941A08F981'),
serverPublicKeyFingerprints: [Long.fromString('c3b42b026ce86b21', 16)],
}
expect(TlSerializationCounter.countNeededBytes(map, obj)).to.equal(32)
const w = TlBinaryWriter.alloc(map, 48)
w.long(Long.ZERO)
w.long(Long.fromString('51E57AC91E83C801', true, 16)) // messageId
w.object(obj)
expect(p.hexEncode(w.result())).eq(
'000000000000000001c8831ec97ae551632416050817ed48941a08f98100000015c4b51c01000000216be86c022bb4c3',
)
})
})

View file

@ -1,39 +0,0 @@
import { NodePlatform } from '@mtcute/node'
import { tl } from '@mtcute/tl'
import { TlBinaryReader, TlBinaryWriter } from '@mtcute/tl-runtime'
import { __tlReaderMap } from '@mtcute/tl/binary/reader.js'
import { __tlWriterMap } from '@mtcute/tl/binary/writer.js'
import { expect } from 'chai'
import Long from 'long'
import { describe, it } from 'mocha'
// here we primarily want to check that @mtcute/tl correctly works with @mtcute/tl-runtime
const p = new NodePlatform()
describe('@mtcute/tl', () => {
it('writers map works with TlBinaryWriter', () => {
const obj = {
_: 'inputPeerUser',
userId: 123,
accessHash: Long.fromNumber(456),
}
expect(p.hexEncode(TlBinaryWriter.serializeObject(__tlWriterMap, obj))).to.equal(
'4ca5e8dd7b00000000000000c801000000000000',
)
})
it('readers map works with TlBinaryReader', () => {
const buf = p.hexDecode('4ca5e8dd7b00000000000000c801000000000000')
const obj = TlBinaryReader.deserializeObject(__tlReaderMap, buf)
expect(obj._).equal('inputPeerUser')
expect(obj.userId).equal(123)
expect(obj.accessHash.toString()).equal('456')
})
it('correctly checks for combinator types', () => {
expect(tl.isAnyInputUser({ _: 'inputUserEmpty' })).to.eq(true)
})
})

View file

@ -1,30 +0,0 @@
import { NodeCryptoProvider } from '@mtcute/node/utils.js'
import { ige256Decrypt, ige256Encrypt } from '@mtcute/wasm'
import { expect } from 'chai'
import { before, describe, it } from 'mocha'
before(async () => {
await new NodeCryptoProvider().initialize()
})
describe('@mtcute/wasm', () => {
const key = Buffer.from('5468697320697320616E20696D706C655468697320697320616E20696D706C65', 'hex')
const iv = Buffer.from('6D656E746174696F6E206F6620494745206D6F646520666F72204F70656E5353', 'hex')
const data = Buffer.from('99706487a1cde613bc6de0b6f24b1c7aa448c8b9c3403e3467a8cad89340f53b', 'hex')
const dataEnc = Buffer.from('792ea8ae577b1a66cb3bd92679b8030ca54ee631976bd3a04547fdcb4639fa69', 'hex')
it('should work with Buffers', () => {
expect(ige256Encrypt(data, key, iv)).to.deep.equal(new Uint8Array(dataEnc))
expect(ige256Decrypt(dataEnc, key, iv)).to.deep.equal(new Uint8Array(data))
})
it('should work with Uint8Arrays', () => {
expect(ige256Encrypt(new Uint8Array(data), new Uint8Array(key), new Uint8Array(iv))).to.deep.equal(
new Uint8Array(dataEnc),
)
expect(ige256Decrypt(new Uint8Array(dataEnc), new Uint8Array(key), new Uint8Array(iv))).to.deep.equal(
new Uint8Array(data),
)
})
})

View file

@ -1,23 +0,0 @@
import { MemoryStorage } from '@mtcute/core'
import { setPlatform } from '@mtcute/core/platform.js'
import { LogManager } from '@mtcute/core/utils.js'
import { NodePlatform, TcpTransport } from '@mtcute/node'
import { NodeCryptoProvider } from '@mtcute/node/utils.js'
export function getApiParams() {
if (!process.env.API_ID || !process.env.API_HASH) {
throw new Error('API_ID and API_HASH env variables must be set')
}
setPlatform(new NodePlatform())
return {
apiId: Number.parseInt(process.env.API_ID),
apiHash: process.env.API_HASH,
testMode: true,
storage: new MemoryStorage(),
logLevel: LogManager.DEBUG,
transport: () => new TcpTransport(),
crypto: new NodeCryptoProvider(),
}
}

View file

@ -1,35 +0,0 @@
{
"name": "mtcute-e2e",
"private": true,
"dependencies": {
"@mtcute/bun": "*",
"@mtcute/core": "*",
"@mtcute/crypto-node": "*",
"@mtcute/dispatcher": "*",
"@mtcute/file-id": "*",
"@mtcute/html-parser": "*",
"@mtcute/http-proxy": "*",
"@mtcute/i18n": "*",
"@mtcute/markdown-parser": "*",
"@mtcute/mtproxy": "*",
"@mtcute/node": "*",
"@mtcute/socks-proxy": "*",
"@mtcute/tl": "*",
"@mtcute/tl-runtime": "*",
"@mtcute/tl-utils": "*",
"@mtcute/wasm": "*",
"@mtcute/web": "*",
"@types/chai": "^4.3.8",
"@types/mocha": "^10.0.2",
"chai": "^4.3.10",
"dotenv": "16.3.1",
"glob": "10.3.10",
"long": "^5.2.3",
"mocha": "^10.2.0",
"ts-node": "^10.9.1",
"typescript": "^5.2.2"
},
"devDependencies": {
"@types/node": "^20.8.10"
}
}

View file

@ -1,2 +0,0 @@
packages:
- .

View file

@ -1,80 +0,0 @@
// this scripts publishes our e2e-tested builds to canary npm
// at this point, we should have all our packages installed in node_modules
// so it should be safe to just cd into them and run `npm publish` on them
const { execSync } = require('node:child_process')
const fs = require('node:fs')
const path = require('node:path')
// setup tokenw
const { NPM_TOKEN, REGISTRY, CURRENT_COMMIT } = process.env
if (!NPM_TOKEN || !REGISTRY || !CURRENT_COMMIT) {
console.error('Missing NPM_TOKEN, REGISTRY or CURRENT_COMMIT env variables!')
process.exit(1)
}
execSync(`npm config set //${REGISTRY.replace(/^https?:\/\//, '')}/:_authToken ${NPM_TOKEN}`, { stdio: 'inherit' })
const commit = CURRENT_COMMIT.slice(0, 7)
const myPkgJson = JSON.parse(fs.readFileSync(path.join(__dirname, 'package.json'), 'utf8'))
const packages = Object.keys(myPkgJson.dependencies)
.filter(x => x.startsWith('@mtcute/'))
.map(x => x.slice('@mtcute/'.length))
const workDir = path.join(__dirname, 'temp')
fs.mkdirSync(workDir, { recursive: true })
async function main() {
const versions = {}
function fixDependencies(pkgJson, key) {
if (!pkgJson[key]) return
const deps = pkgJson[key]
for (const dep of Object.keys(deps)) {
if (!dep.startsWith('@mtcute/')) continue
deps[dep] = versions[dep.slice('@mtcute/'.length)]
}
}
// prepare working directory
for (const pkg of packages) {
const data = await fetch(`http://localhost:4873/@mtcute/${pkg}`).then(x => x.json())
const version = data['dist-tags'].latest
const tarball = data.versions[version].dist.tarball
execSync(`wget -O ${pkg}.tgz ${tarball}`, { cwd: workDir, stdio: 'inherit' })
execSync(`tar -xzf ${pkg}.tgz`, { cwd: workDir, stdio: 'inherit' })
execSync(`rm ${pkg}.tgz`, { cwd: workDir, stdio: 'inherit' })
execSync(`mv package ${pkg}`, { cwd: workDir, stdio: 'inherit' })
versions[pkg] = `${version}-git.${commit}`
}
for (const pkg of packages) {
const pkgDir = path.join(workDir, pkg)
const pkgJsonPath = path.join(pkgDir, 'package.json')
const pkgJson = JSON.parse(fs.readFileSync(pkgJsonPath, 'utf8'))
fixDependencies(pkgJson, 'dependencies')
fixDependencies(pkgJson, 'peerDependencies')
fixDependencies(pkgJson, 'devDependencies')
fixDependencies(pkgJson, 'optionalDependencies')
pkgJson.version = versions[pkg]
fs.writeFileSync(pkgJsonPath, JSON.stringify(pkgJson, null, 4))
execSync(`npm publish --registry ${REGISTRY} -q --tag canary`, {
cwd: pkgDir,
stdio: 'inherit',
})
}
}
main().catch((err) => {
console.error(err)
process.exit(1)
})

View file

@ -1,136 +0,0 @@
/* eslint-disable no-console */
const cp = require('node:child_process')
const path = require('node:path')
const glob = require('glob')
const env = {}
require('dotenv').config({ processEnv: env })
const config = require('./config')
const DIRS = Object.keys(config)
function runForFile(dir, file, single = true) {
const { runFile, beforeAll } = config[dir]
if (!runFile) {
console.log('No runFile for %s', dir)
return
}
let cmds = runFile(file)
if (!cmds) {
return
}
const options = {
env: {
...env,
...process.env,
},
cwd: path.join(__dirname, dir),
stdio: 'inherit',
}
if (!Array.isArray(cmds)) {
cmds = [cmds]
}
if (beforeAll && single) {
cmds.unshift(...beforeAll())
}
for (const c of cmds) {
console.log('%s $ %s', dir, c)
cp.execSync(`pnpm exec ${c}`, options)
}
}
function runForDir(dir) {
const { getFiles, beforeAll } = config[dir]
if (!getFiles) {
console.log('No getFiles for %s', dir)
return
}
const options = {
env: {
...env,
...process.env,
},
cwd: path.join(__dirname, dir),
stdio: 'inherit',
}
if (beforeAll) {
for (const c of beforeAll()) {
console.log('%s $ %s', dir, c)
cp.execSync(`pnpm exec ${c}`, options)
}
}
const files = glob.sync(getFiles(), { cwd: path.join(__dirname, dir) })
files.sort()
for (const file of files) {
runForFile(dir, file, false)
}
}
async function main() {
if (!process.argv[2]) {
console.log('Usage: node runner.js <what>')
console.log(' where <what> is one of:')
console.log(' publish-canary - publish everything to canary npm')
console.log(' all - run all tests')
console.log(' <dirname> - (one of %s) - run tests for that directory', DIRS.join(', '))
console.log(' <dirname> <filename> - run tests for that file')
process.exit(1)
}
const [dir, file] = process.argv.slice(2)
if (dir === 'publish-canary') {
cp.execSync('node publish-canary.js', { stdio: 'inherit' })
return
}
if (dir === 'all') {
for (const d of DIRS) {
console.log('Entering %s', d)
runForDir(d)
}
return
}
if (!DIRS.includes(dir)) {
console.log('Unknown directory %s', dir)
process.exit(1)
}
if (file) {
const files = glob.sync(config[dir].getFiles(), { cwd: path.join(__dirname, dir) })
const matchingFile = files.find(f => f.endsWith(file))
if (!matchingFile) {
console.log("Can't find file %s", file)
process.exit(1)
}
runForFile(dir, matchingFile)
} else {
runForDir(dir)
}
}
main().catch((e) => {
console.error(e)
process.exit(1)
})

View file

@ -1,52 +0,0 @@
const cp = require('node:child_process')
const fs = require('node:fs')
const path = require('node:path')
const glob = require('glob')
function fixForEsm() {
const modified = {}
fs.writeFileSync(path.join(__dirname, 'package.json'), JSON.stringify({ type: 'module' }))
for (const file of glob.sync('tests/**/*.ts')) {
let content = fs.readFileSync(file, 'utf8')
if (content.includes('@fix-import')) {
modified[file] = content
content = content.replace(/(?<=@fix-import\nimport.*?')(.*?)(?='$)/gms, '$1.js')
fs.writeFileSync(file, content)
}
}
return () => {
fs.writeFileSync(path.join(__dirname, 'package.json'), JSON.stringify({ type: 'commonjs' }))
for (const file of Object.keys(modified)) {
fs.writeFileSync(file, modified[file])
}
}
}
exports.fixForEsm = fixForEsm
function main() {
const restore = fixForEsm()
let error = null
try {
cp.execSync('pnpm exec tsc --outDir dist/esm', { stdio: 'inherit' })
fs.writeFileSync(path.join(__dirname, 'dist/esm/package.json'), JSON.stringify({ type: 'module' }))
} catch (e) {
error = e
}
restore()
if (error) {
throw error
}
}
if (require.main === module) {
main()
}

View file

@ -1,3 +0,0 @@
{
"node-option": ["experimental-specifier-resolution=node", "loader=ts-node/esm"]
}

View file

@ -1 +0,0 @@
{ "type": "commonjs" }

View file

@ -1,25 +0,0 @@
const cp = require('node:child_process')
const { fixForEsm } = require('./build-esm.cjs')
const file = process.argv[2]
if (!file) {
console.error('Usage: run-esm.cjs <file>')
process.exit(1)
}
let error = null
const restore = fixForEsm()
try {
cp.execSync(`pnpm exec mocha --config=mocha.esm.json ${file}`, { stdio: 'inherit' })
} catch (e) {
error = e
}
restore()
if (error) {
throw error
}

View file

@ -1,84 +0,0 @@
import { tl, User } from '@mtcute/core'
import { BaseTelegramClient, TelegramClient } from '@mtcute/core/client.js'
import { expect } from 'chai'
import { describe, it } from 'mocha'
import { getApiParams } from '../utils.js'
function getAccountId() {
return Math.floor(Math.random() * 10000)
.toString()
.padStart(4, '0')
}
async function authorizeInDc(dc: number, base: BaseTelegramClient) {
const tg = new TelegramClient({ client: base })
while (true) {
await base.mt.storage.load()
await base.storage.clear(true)
const phone = `99966${dc}${getAccountId()}`
let user
try {
const sentCode = await tg.sendCode({ phone })
let auth = await tg.call({
_: 'auth.signIn',
phoneNumber: phone,
phoneCode: `${dc}${dc}${dc}${dc}${dc}`,
phoneCodeHash: sentCode.phoneCodeHash,
})
if (auth._ === 'auth.authorizationSignUpRequired') {
auth = await tg.call({
_: 'auth.signUp',
phoneNumber: phone,
phoneCodeHash: sentCode.phoneCodeHash,
firstName: 'mtcute e2e',
lastName: '',
})
if (auth._ !== 'auth.authorization') {
throw new Error('Unexpected response')
}
}
await tg.notifyLoggedIn(auth)
user = new User(auth.user)
} catch (e) {
if (tl.RpcError.is(e, 'SESSION_PASSWORD_NEEDED') || tl.RpcError.is(e, 'PHONE_NUMBER_FLOOD')) {
// retry with another number
await tg.close()
continue
}
throw e
}
await tg.close()
expect(user.isSelf).to.eq(true)
expect(user.phoneNumber).to.equal(phone)
break
}
}
describe('1. authorization', function () {
this.timeout(300_000)
it('should authorize in default dc', async () => {
const base = new BaseTelegramClient(getApiParams('dc2.session'))
await authorizeInDc(2, base)
})
it('should authorize in dc 1', async () => {
const base = new BaseTelegramClient(getApiParams('dc1.session'))
await authorizeInDc(1, base)
})
})

View file

@ -1,92 +0,0 @@
import type { Message } from '@mtcute/node'
import type { CustomMethods } from './_worker.js'
import path from 'node:path'
import { Worker } from 'node:worker_threads'
import { TelegramClient } from '@mtcute/core/client.js'
import { Long, TelegramWorkerPort, tl } from '@mtcute/node'
import { expect } from 'chai'
import { describe, it } from 'mocha'
import { getApiParams, waitFor } from '../utils.js'
describe('5. worker', function () {
this.timeout(300_000)
const worker = new Worker(path.resolve(__dirname, '_worker.js'))
const port = new TelegramWorkerPort<CustomMethods>({
worker,
})
const portClient = new TelegramClient({ client: port })
it('should make api calls', async () => {
const res = await port.call({ _: 'help.getConfig' })
expect(res._).to.equal('config')
const premiumPromo = await port.call({ _: 'help.getPremiumPromo' })
// ensure Long-s are correctly serialized
expect(Long.isLong((premiumPromo.users[0] as tl.RawUser).accessHash)).to.equal(true)
})
it('should call custom methods', async () => {
const hello = await port.invokeCustom('hello')
expect(hello).to.equal('world')
const sum = await port.invokeCustom('sum', 2, 3)
expect(sum).to.equal(5)
})
it('should throw errors', async () => {
try {
await port.call({ _: 'test.useConfigSimple' })
throw new Error('should have thrown')
} catch (e) {
expect(e).to.be.an.instanceOf(tl.RpcError)
}
})
it('should receive updates', async () => {
const client2 = new TelegramClient(getApiParams('dc2.session'))
try {
await client2.connect()
await port.startUpdatesLoop()
const me = await portClient.getMe()
// ensure Long-s are correctly serialized
expect(Long.isLong(me.raw.accessHash)).equals(true)
let username = me.username
if (!username) {
username = `mtcute_e2e_${Math.random().toString(36).slice(2, 8)}`
await portClient.setMyUsername(username)
}
const msgs: Message[] = []
portClient.on('new_message', (msg) => {
msgs.push(msg)
})
const testText = `test ${Math.random()}`
await client2.sendText(username, testText)
await waitFor(() => {
expect(msgs.length).to.be.greaterThan(0)
expect(msgs[0].text).to.equal(testText)
})
} catch (e) {
await client2.close()
throw e
}
await client2.close()
})
this.afterAll(async () => {
await port.close()
void worker.terminate()
})
})

View file

@ -1,24 +0,0 @@
import { BaseTelegramClient } from '@mtcute/core/client.js'
import { expect } from 'chai'
import { describe, it } from 'mocha'
// @fix-import
import { getApiParams } from '../../utils'
describe('@mtcute/core', function () {
this.timeout(300_000)
it('connects to test DC and makes help.getNearestDc', async () => {
const tg = new BaseTelegramClient({
...getApiParams(),
})
await tg.connect()
const config = await tg.call({ _: 'help.getNearestDc' })
await tg.close()
expect(config).to.be.an('object')
expect(config._).to.equal('nearestDc')
expect(config.thisDc).to.equal(2)
})
})

View file

@ -1,115 +0,0 @@
/* eslint-disable */
import { expect } from 'chai'
import Long from 'long'
import { describe, it } from 'mocha'
import { TlBinaryReader, TlBinaryWriter, TlSerializationCounter } from '@mtcute/tl-runtime'
import { NodePlatform } from '@mtcute/node'
import { setPlatform } from '@mtcute/core/platform.js'
// here we primarily want to check that everything imports properly,
// and that the code is actually executable. The actual correctness
// of the implementation is covered tested by unit tests
const p = new NodePlatform()
setPlatform(p)
describe('encodings', () => {
it('works with Buffers', () => {
expect(p.hexEncode(Buffer.from('hello'))).to.equal('68656c6c6f')
expect(p.hexDecode('0102030405')).eql(Buffer.from([1, 2, 3, 4, 5]))
})
it('works with Uint8Arrays', () => {
expect(p.hexEncode(new Uint8Array([1, 2, 3, 4, 5]))).to.equal('0102030405')
})
})
describe('TlBinaryReader', () => {
const map = {
'85337187': function (r: any) {
const ret: any = {}
ret._ = 'mt_resPQ'
ret.nonce = r.int128()
ret.serverNonce = r.int128()
ret.pq = r.bytes()
ret.serverPublicKeyFingerprints = r.vector(r.long)
return ret
},
}
const data =
'000000000000000001c8831ec97ae55140000000632416053e0549828cca27e966b301a48fece2fca5cf4d33f4a11ea877ba4aa5739073300817ed48941a08f98100000015c4b51c01000000216be86c022bb4c3'
it('should work with Buffers', () => {
const buf = Buffer.from(data, 'hex')
const r = new TlBinaryReader(map, buf, 8)
expect(r.long().toString(16)).to.equal('51e57ac91e83c801')
expect(r.uint()).to.equal(64)
const obj: any = r.object()
expect(obj._).equal('mt_resPQ')
})
it('should work with Uint8Arrays', () => {
const buf = p.hexDecode(data)
const r = new TlBinaryReader(map, buf, 8)
expect(r.long().toString(16)).to.equal('51e57ac91e83c801')
expect(r.uint()).to.equal(64)
const obj: any = r.object()
expect(obj._).equal('mt_resPQ')
})
})
describe('TlBinaryWriter', () => {
const map = {
mt_resPQ: function (w: any, obj: any) {
w.uint(85337187)
w.bytes(obj.pq)
w.vector(w.long, obj.serverPublicKeyFingerprints)
},
_staticSize: {} as any,
}
it('should work with Buffers', () => {
const obj = {
_: 'mt_resPQ',
pq: Buffer.from('17ED48941A08F981', 'hex'),
serverPublicKeyFingerprints: [Long.fromString('c3b42b026ce86b21', 16)],
}
expect(TlSerializationCounter.countNeededBytes(map, obj)).to.equal(32)
const w = TlBinaryWriter.alloc(map, 48)
w.long(Long.ZERO)
w.long(Long.fromString('51E57AC91E83C801', true, 16)) // messageId
w.object(obj)
expect(p.hexEncode(w.result())).eq(
'000000000000000001c8831ec97ae551632416050817ed48941a08f98100000015c4b51c01000000216be86c022bb4c3',
)
})
it('should work with Uint8Arrays', () => {
const obj = {
_: 'mt_resPQ',
pq: p.hexDecode('17ED48941A08F981'),
serverPublicKeyFingerprints: [Long.fromString('c3b42b026ce86b21', 16)],
}
expect(TlSerializationCounter.countNeededBytes(map, obj)).to.equal(32)
const w = TlBinaryWriter.alloc(map, 48)
w.long(Long.ZERO)
w.long(Long.fromString('51E57AC91E83C801', true, 16)) // messageId
w.object(obj)
expect(p.hexEncode(w.result())).eq(
'000000000000000001c8831ec97ae551632416050817ed48941a08f98100000015c4b51c01000000216be86c022bb4c3',
)
})
})

View file

@ -1,41 +0,0 @@
import { setPlatform } from '@mtcute/core/platform.js'
import { NodePlatform } from '@mtcute/node'
import { tl } from '@mtcute/tl'
import { TlBinaryReader, TlBinaryWriter } from '@mtcute/tl-runtime'
import { __tlReaderMap } from '@mtcute/tl/binary/reader.js'
import { __tlWriterMap } from '@mtcute/tl/binary/writer.js'
import { expect } from 'chai'
import Long from 'long'
import { describe, it } from 'mocha'
// here we primarily want to check that @mtcute/tl correctly works with @mtcute/tl-runtime
const p = new NodePlatform()
setPlatform(p)
describe('@mtcute/tl', () => {
it('writers map works with TlBinaryWriter', () => {
const obj = {
_: 'inputPeerUser',
userId: 123,
accessHash: Long.fromNumber(456),
}
expect(p.hexEncode(TlBinaryWriter.serializeObject(__tlWriterMap, obj))).to.equal(
'4ca5e8dd7b00000000000000c801000000000000',
)
})
it('readers map works with TlBinaryReader', () => {
const buf = p.hexDecode('4ca5e8dd7b00000000000000c801000000000000')
const obj = TlBinaryReader.deserializeObject<any>(__tlReaderMap, buf)
expect(obj._).equal('inputPeerUser')
expect(obj.userId).equal(123)
expect(obj.accessHash.toString()).equal('456')
})
it('correctly checks for combinator types', () => {
expect(tl.isAnyInputUser({ _: 'inputUserEmpty' })).to.eq(true)
})
})

View file

@ -1,30 +0,0 @@
import { NodeCryptoProvider } from '@mtcute/node/utils.js'
import { ige256Decrypt, ige256Encrypt } from '@mtcute/wasm'
import { expect } from 'chai'
import { before, describe, it } from 'mocha'
before(async () => {
await new NodeCryptoProvider().initialize()
})
describe('@mtcute/wasm', () => {
const key = Buffer.from('5468697320697320616E20696D706C655468697320697320616E20696D706C65', 'hex')
const iv = Buffer.from('6D656E746174696F6E206F6620494745206D6F646520666F72204F70656E5353', 'hex')
const data = Buffer.from('99706487a1cde613bc6de0b6f24b1c7aa448c8b9c3403e3467a8cad89340f53b', 'hex')
const dataEnc = Buffer.from('792ea8ae577b1a66cb3bd92679b8030ca54ee631976bd3a04547fdcb4639fa69', 'hex')
it('should work with Buffers', () => {
expect(ige256Encrypt(data, key, iv)).to.deep.equal(new Uint8Array(dataEnc))
expect(ige256Decrypt(dataEnc, key, iv)).to.deep.equal(new Uint8Array(data))
})
it('should work with Uint8Arrays', () => {
expect(ige256Encrypt(new Uint8Array(data), new Uint8Array(key), new Uint8Array(iv))).to.deep.equal(
new Uint8Array(dataEnc),
)
expect(ige256Decrypt(new Uint8Array(dataEnc), new Uint8Array(key), new Uint8Array(iv))).to.deep.equal(
new Uint8Array(data),
)
})
})

View file

@ -1,28 +0,0 @@
{
"compilerOptions": {
"incremental": true,
"target": "es2020",
"rootDir": ".",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"allowJs": true,
"strict": true,
"noImplicitAny": true,
"noImplicitThis": true,
"declaration": true,
"inlineSources": true,
"outDir": "./dist",
"sourceMap": true,
"stripInternal": true,
"allowSyntheticDefaultImports": true,
"esModuleInterop": true,
"skipLibCheck": true
},
"include": [
"./tests",
"./utils.ts"
],
"exclude": [
"**/node_modules"
]
}

30
e2e/package.json Normal file
View file

@ -0,0 +1,30 @@
{
"name": "@mtcute/e2e-tests",
"type": "module",
"version": "0.0.0",
"private": true,
"fuman": {
"private": true
},
"scripts": {
"test": "dotenv tsx --trace-warnings --test --test-timeout=300000",
"test:all": "find tests -type f -name '*.e2e.ts' -print0 | sort -z | xargs -0 -n1 pnpm run test",
"deno:test": "deno test -A --unstable-ffi --trace-leaks --import-map=import-map.json --unstable-sloppy-imports --no-check --env-file=.env",
"deno:test:all": "pnpm run deno:test tests/*.e2e.ts"
},
"dependencies": {
"@fuman/utils": "0.0.1",
"chai": "^4.3.10",
"dotenv-cli": "7.4.4",
"mtcute": "file:../packages/node",
"tsx": "^4.19.2",
"esbuild": "^0.24.0",
"better-sqlite3": "11.6.0"
},
"devDependencies": {
"@types/chai": "^4.3.8",
"@types/mocha": "^10.0.2",
"@types/node": "^20.8.10",
"globstar": "1.0.0"
}
}

27
e2e/tests/01.auth.e2e.ts Normal file
View file

@ -0,0 +1,27 @@
import { describe, it } from 'node:test'
import { expect } from 'chai'
import { TelegramClient, User } from 'mtcute'
import { getApiParams } from './_utils.js'
describe('1. authorization', () => {
it('should authorize in default dc', async () => {
const tg = new TelegramClient(getApiParams('dc2.session'))
await tg.importSession(process.env.SESSION_DC2!)
expect(await tg.getMe()).to.be.instanceOf(User)
await tg.close()
})
it('should authorize in dc 1', async () => {
const tg = new TelegramClient(getApiParams('dc1.session'))
await tg.importSession(process.env.SESSION_DC1!)
expect(await tg.getMe()).to.be.instanceOf(User)
await tg.close()
})
})

View file

@ -1,16 +1,14 @@
import { MtPeerNotFoundError } from '@mtcute/core' import { after, before, describe, it } from 'node:test'
import { TelegramClient } from '@mtcute/core/client.js'
import { expect } from 'chai' import { expect } from 'chai'
import { describe, it } from 'mocha' import { TelegramClient } from 'mtcute'
import { getApiParams } from '../utils.js' import { getApiParams } from './_utils.js'
describe('2. calling methods', function () { describe('2. calling methods', () => {
this.timeout(300_000)
const tg = new TelegramClient(getApiParams('dc2.session')) const tg = new TelegramClient(getApiParams('dc2.session'))
this.beforeAll(() => tg.connect()) before(() => tg.connect())
this.afterAll(() => tg.close()) after(() => tg.close())
it('getUsers(@BotFather)', async () => { it('getUsers(@BotFather)', async () => {
const [user] = await tg.getUsers('botfather') const [user] = await tg.getUsers('botfather')
@ -27,16 +25,7 @@ describe('2. calling methods', function () {
}) })
it('getHistory(777000)', async () => { it('getHistory(777000)', async () => {
try { await tg.findDialogs(777000) // ensure it's cached
await tg.findDialogs(777000) // ensure it's cached
} catch (e) {
if (e instanceof MtPeerNotFoundError) {
// this happens sometimes :D gracefully skip
return
}
throw e
}
const history = await tg.getHistory(777000, { limit: 5 }) const history = await tg.getHistory(777000, { limit: 5 })

View file

@ -1,13 +1,12 @@
import type { FileDownloadLocation } from '@mtcute/core' import type { FileDownloadLocation } from 'mtcute'
import { createHash } from 'node:crypto' import { createHash } from 'node:crypto'
import { Thumbnail } from '@mtcute/core' import { after, before, describe, it } from 'node:test'
import { TelegramClient } from '@mtcute/core/client.js' import { sleep } from '@fuman/utils'
import { sleep } from '@mtcute/core/utils.js'
import { expect } from 'chai' import { expect } from 'chai'
import { describe, it } from 'mocha' import { TelegramClient, Thumbnail } from 'mtcute'
import { getApiParams } from '../utils.js' import { getApiParams } from './_utils.js'
const CINNAMOROLL_PFP_CHAT = 'test_file_dc2' const CINNAMOROLL_PFP_CHAT = 'test_file_dc2'
const CINNAMOROLL_PFP_THUMB_SHA256 = '3e6f220235a12547c16129f50c19ed3224d39b827414d1d500f79569a3431eae' const CINNAMOROLL_PFP_THUMB_SHA256 = '3e6f220235a12547c16129f50c19ed3224d39b827414d1d500f79569a3431eae'
@ -31,18 +30,15 @@ async function downloadAsSha256(client: TelegramClient, location: FileDownloadLo
return sha.digest('hex') return sha.digest('hex')
} }
describe('3. working with files', function () { // sometimes test dcs are overloaded and we get FILE_REFERENCE_EXPIRED
this.timeout(300_000) // because we got multiple -500:No workers running errors in a row
// sometimes test dcs are overloaded and we get FILE_REFERENCE_EXPIRED // we currently don't have file references database, so we can just retry the test for now
// because we got multiple -500:No workers running errors in a row describe('3. working with files', () => {
// we currently don't have file references database, so we can just retry the test for now
this.retries(2)
describe('same-dc', () => { describe('same-dc', () => {
const tg = new TelegramClient(getApiParams('dc2.session')) const tg = new TelegramClient(getApiParams('dc2.session'))
this.beforeAll(() => tg.connect()) before(() => tg.connect())
this.afterAll(() => tg.close()) after(() => tg.close())
it('should download pfp thumbs', async () => { it('should download pfp thumbs', async () => {
const chat = await tg.getChat(CINNAMOROLL_PFP_CHAT) const chat = await tg.getChat(CINNAMOROLL_PFP_CHAT)
@ -107,6 +103,7 @@ describe('3. working with files', function () {
const promise = download() const promise = download()
// let it download for 10 seconds // let it download for 10 seconds
// file is 1gb so it is safe to assume it will take more than that to download
await sleep(10000) await sleep(10000)
abort.abort() abort.abort()
// abort and snap the downloaded amount // abort and snap the downloaded amount
@ -127,8 +124,8 @@ describe('3. working with files', function () {
describe('cross-dc', () => { describe('cross-dc', () => {
const tg = new TelegramClient(getApiParams('dc1.session')) const tg = new TelegramClient(getApiParams('dc1.session'))
this.beforeAll(() => tg.connect()) before(() => tg.connect())
this.afterAll(() => tg.close()) after(() => tg.close())
it('should download pfp thumbs', async () => { it('should download pfp thumbs', async () => {
const chat = await tg.getChat(CINNAMOROLL_PFP_CHAT) const chat = await tg.getChat(CINNAMOROLL_PFP_CHAT)

View file

@ -1,24 +1,23 @@
import type { Message } from '@mtcute/core' import type { Message } from 'mtcute'
import { TelegramClient } from '@mtcute/core/client.js' import { after, before, describe, it } from 'node:test'
import { asNonNull } from '@fuman/utils'
import { expect } from 'chai' import { expect } from 'chai'
import { describe, it } from 'mocha'
import { getApiParams, waitFor } from '../utils.js' import { TelegramClient } from 'mtcute'
import { getApiParams, waitFor } from './_utils.js'
describe('4. handling updates', function () {
this.timeout(300_000)
describe('4. handling updates', () => {
const tg1 = new TelegramClient(getApiParams('dc1.session')) const tg1 = new TelegramClient(getApiParams('dc1.session'))
tg1.log.prefix = '[tg1] ' tg1.log.prefix = '[tg1] '
const tg2 = new TelegramClient(getApiParams('dc2.session')) const tg2 = new TelegramClient(getApiParams('dc2.session'))
tg2.log.prefix = '[tg2] ' tg2.log.prefix = '[tg2] '
this.beforeAll(async () => { before(async () => {
await tg1.connect() await tg1.connect()
await tg1.startUpdatesLoop() await tg1.startUpdatesLoop()
await tg2.connect() await tg2.connect()
}) })
this.afterAll(async () => { after(async () => {
await tg1.close() await tg1.close()
await tg2.close() await tg2.close()
}) })
@ -26,15 +25,10 @@ describe('4. handling updates', function () {
it('should send and receive messages', async () => { it('should send and receive messages', async () => {
const tg1Messages: Message[] = [] const tg1Messages: Message[] = []
tg1.on('new_message', msg => tg1Messages.push(msg)) tg1.onNewMessage.add(msg => tg1Messages.push(msg))
const [tg1User] = await tg1.getUsers('self') const [tg1User] = await tg1.getUsers('self')
let username = tg1User!.username const username = asNonNull(tg1User!.username)
if (!username) {
username = `mtcute_e2e_${Math.random().toString(36).slice(2)}`
await tg1.setMyUsername(username)
}
const messageText = `mtcute test message ${Math.random().toString(36).slice(2)}` const messageText = `mtcute test message ${Math.random().toString(36).slice(2)}`
const sentMsg = await tg2.sendText(username, messageText) const sentMsg = await tg2.sendText(username, messageText)

127
e2e/tests/05.worker.e2e.ts Normal file
View file

@ -0,0 +1,127 @@
import type { Message } from 'mtcute'
import type { Worker as NodeWorker } from 'node:worker_threads'
import type { CustomMethods } from './_worker.js'
import { writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { after, describe, it } from 'node:test'
import { fileURLToPath } from 'node:url'
import { asNonNull } from '@fuman/utils'
import { expect } from 'chai'
import { build } from 'esbuild'
import { Long, TelegramClient, TelegramWorkerPort, tl } from 'mtcute'
import { getApiParams, IS_BUN, IS_DENO, IS_NODE, RUNTIME_DIR, waitFor } from './_utils.js'
const workerFile = new URL('./_worker.ts', import.meta.url)
let worker: Worker | NodeWorker
if (IS_NODE) {
// because of https://github.com/privatenumber/tsx/issues/354 we can't use tsx directly lol
const compiled = (await build({
entryPoints: [fileURLToPath(workerFile)],
bundle: true,
format: 'esm',
write: false,
target: 'esnext',
platform: 'node',
// NB: we have better-sqlite3 in deps to this package because otherwise node fails to resolve it
external: ['node:*', 'better-sqlite3'],
})).outputFiles[0].text
const compiledWorkerFile = join(RUNTIME_DIR, '_worker.js')
await writeFile(compiledWorkerFile, compiled)
const { Worker } = await import('node:worker_threads')
worker = new Worker(compiledWorkerFile)
worker.on('exit', (code) => {
console.error('worker exited with code', code)
})
worker.on('error', (err) => {
console.error('worker error', err)
process.exit(1)
})
} else if (IS_BUN) {
const { Worker } = await import('node:worker_threads')
worker = new Worker(workerFile)
worker.on('exit', (code) => {
console.error('worker exited with code', code)
})
worker.on('error', (err) => {
console.error('worker error', err)
process.exit(1)
})
} else if (IS_DENO) {
worker = new Worker(workerFile, { type: 'module' })
}
describe('5. worker', () => {
const port = new TelegramWorkerPort<CustomMethods>({
worker,
})
const portClient = new TelegramClient({ client: port })
it('should make api calls', async () => {
const res = await port.call({ _: 'help.getConfig' })
expect(res._).to.equal('config')
const premiumPromo = await port.call({ _: 'help.getPremiumPromo' })
// ensure Long-s are correctly serialized
expect(Long.isLong((premiumPromo.users[0] as tl.RawUser).accessHash)).to.equal(true)
})
it('should call custom methods', async () => {
const hello = await port.invokeCustom('hello')
expect(hello).to.equal('world')
const sum = await port.invokeCustom('sum', 2, 3)
expect(sum).to.equal(5)
})
it('should throw errors', async () => {
try {
await port.call({ _: 'test.useConfigSimple' })
throw new Error('should have thrown')
} catch (e) {
expect(e).to.be.an.instanceOf(tl.RpcError)
}
})
it('should receive updates', async () => {
const client2 = new TelegramClient(getApiParams('dc2.session'))
try {
await client2.connect()
await port.startUpdatesLoop()
const me = await portClient.getMe()
// ensure Long-s are correctly serialized
expect(Long.isLong(me.raw.accessHash)).equals(true)
const username = asNonNull(me.username)
const msgs: Message[] = []
portClient.onNewMessage.add((msg) => {
msgs.push(msg)
})
const testText = `mtcute worker test ${Math.random()}`
await client2.sendText(username, testText)
await waitFor(() => {
expect(msgs.length).to.be.greaterThan(0)
expect(msgs[0].text).to.equal(testText)
})
} catch (e) {
await client2.close()
throw e
}
await client2.close()
})
after(async () => {
await port.close()
void worker.terminate()
})
})

View file

@ -1,28 +1,27 @@
import type { MaybePromise } from '@mtcute/core' import type { BaseTelegramClientOptions, MaybePromise } from 'mtcute'
import type { BaseTelegramClientOptions } from '@mtcute/core/client.js'
import { join } from 'node:path' import { join } from 'node:path'
import { MemoryStorage } from '@mtcute/core' import { fileURLToPath } from 'node:url'
import { setPlatform } from '@mtcute/core/platform.js' import { sleep } from '@fuman/utils'
import { LogManager, sleep } from '@mtcute/core/utils.js' import { MemoryStorage, SqliteStorage } from 'mtcute'
import { NodePlatform, SqliteStorage, TcpTransport } from '@mtcute/node' import { LogManager } from 'mtcute/utils.js'
import { NodeCryptoProvider } from '@mtcute/node/utils.js'
export const RUNTIME_DIR: string = fileURLToPath(new URL('../_runtime', import.meta.url))
export const IS_DENO = 'Deno' in globalThis
export const IS_BUN = 'Bun' in globalThis
export const IS_NODE = !IS_DENO && !IS_BUN
export function getApiParams(storage?: string): BaseTelegramClientOptions { export function getApiParams(storage?: string): BaseTelegramClientOptions {
if (!process.env.API_ID || !process.env.API_HASH) { if (!process.env.API_ID || !process.env.API_HASH) {
throw new Error('API_ID and API_HASH env variables must be set') throw new Error('API_ID and API_HASH env variables must be set')
} }
setPlatform(new NodePlatform())
return { return {
apiId: Number.parseInt(process.env.API_ID), apiId: Number.parseInt(process.env.API_ID),
apiHash: process.env.API_HASH, apiHash: process.env.API_HASH,
testMode: true, testMode: true,
storage: storage ? new SqliteStorage(join(__dirname, storage)) : new MemoryStorage(), storage: storage ? new SqliteStorage(join(RUNTIME_DIR, storage)) : new MemoryStorage(),
logLevel: LogManager.VERBOSE, logLevel: LogManager.VERBOSE,
transport: () => new TcpTransport(),
crypto: new NodeCryptoProvider(),
} }
} }

View file

@ -1,7 +1,6 @@
import type { WorkerCustomMethods } from '@mtcute/core/worker.js' import type { WorkerCustomMethods } from 'mtcute'
import { BaseTelegramClient, TelegramWorker } from '@mtcute/node' import { BaseTelegramClient, TelegramWorker } from 'mtcute'
import { getApiParams } from './_utils.ts'
import { getApiParams } from '../utils.js'
const customMethods = { const customMethods = {
hello: async () => 'world', hello: async () => 'world',

View file

@ -0,0 +1,17 @@
import * as bdd from 'jsr:@std/testing@1.0.5/bdd'
export function describe(name, opts, fn) {
if (typeof opts === 'function') {
fn = opts
opts = {}
}
bdd.describe(name, {
...opts,
// we don't close @db/sqlite
sanitizeResources: false,
}, fn)
}
export const it = bdd.it
export const before = bdd.beforeAll
export const after = bdd.afterAll

View file

@ -0,0 +1,5 @@
import { createRequire } from 'node:module'
const require = createRequire(import.meta.url)
export const { __tlReaderMap } = require('../../../packages/tl/binary/reader.js')

View file

@ -0,0 +1,5 @@
import { createRequire } from 'node:module'
const require = createRequire(import.meta.url)
export const { __publicKeyIndex } = require('../../../packages/tl/binary/rsa-keys.js')

View file

@ -0,0 +1,5 @@
import { createRequire } from 'node:module'
const require = createRequire(import.meta.url)
export const { __tlWriterMap } = require('../../../packages/tl/binary/writer.js')

View file

@ -0,0 +1,5 @@
import { createRequire } from 'node:module'
const require = createRequire(import.meta.url)
export const { tl, mtp } = require('../../../packages/tl/index.js')

10
e2e/tsconfig.json Normal file
View file

@ -0,0 +1,10 @@
{
"extends": "../tsconfig.json",
"compilerOptions": {
"allowImportingTsExtensions": true,
"isolatedDeclarations": false
},
"include": [
"./tests"
]
}

View file

@ -2,6 +2,7 @@ import antfu from '@antfu/eslint-config'
export default antfu({ export default antfu({
type: 'lib', type: 'lib',
ignores: ['e2e/runtime/*'],
typescript: process.env.CI typescript: process.env.CI
? { ? {
tsconfigPath: 'tsconfig.json', tsconfigPath: 'tsconfig.json',

View file

@ -81,12 +81,12 @@ export async function openWebview(
* - `unigram` - Unigram * - `unigram` - Unigram
*/ */
platform: platform:
| 'android' | 'android'
| 'ios' | 'ios'
| 'tdesktop' | 'tdesktop'
| 'macos' | 'macos'
| 'unigram' | 'unigram'
| (string & {}) | (string & {})
}, },
): Promise<WebviewResult> { ): Promise<WebviewResult> {
const { const {

View file

@ -25,14 +25,14 @@ export interface SendCopyGroupParams extends CommonSendParams {
export async function sendCopyGroup( export async function sendCopyGroup(
client: ITelegramClient, client: ITelegramClient,
params: SendCopyGroupParams & params: SendCopyGroupParams &
( (
| { | {
/** Source chat ID */ /** Source chat ID */
fromChatId: InputPeerLike fromChatId: InputPeerLike
/** Message IDs to forward */ /** Message IDs to forward */
messages: number[] messages: number[]
} }
| { messages: Message[] } | { messages: Message[] }
), ),
): Promise<Message[]> { ): Promise<Message[]> {
const { toChatId, ...rest } = params const { toChatId, ...rest } = params

View file

@ -38,14 +38,14 @@ export interface SendCopyParams extends CommonSendParams {
export async function sendCopy( export async function sendCopy(
client: ITelegramClient, client: ITelegramClient,
params: SendCopyParams & params: SendCopyParams &
( (
| { | {
/** Source chat ID */ /** Source chat ID */
fromChatId: InputPeerLike fromChatId: InputPeerLike
/** Message ID to forward */ /** Message ID to forward */
message: number message: number
} }
| { message: Message } | { message: Message }
), ),
): Promise<Message> { ): Promise<Message> {
const { toChatId, ...rest } = params const { toChatId, ...rest } = params

View file

@ -16,106 +16,106 @@ import { makeInspectable } from '../../utils/inspectable.js'
* - `from_link` - webview opened via [direct links](https://corefork.telegram.org/api/bots/webapps#direct-link-mini-apps) * - `from_link` - webview opened via [direct links](https://corefork.telegram.org/api/bots/webapps#direct-link-mini-apps)
*/ */
export type InputWebview = export type InputWebview =
| { | {
type: 'main' type: 'main'
/** /**
* If set, requests to open the mini app in compact mode (as opposed to fullview mode). * If set, requests to open the mini app in compact mode (as opposed to fullview mode).
* Must be set if the `mode` parameter of the Main Mini App link is equal to `compact`. * Must be set if the `mode` parameter of the Main Mini App link is equal to `compact`.
*/ */
compact?: boolean compact?: boolean
/** Start parameter from the deep link for the mini app */ /** Start parameter from the deep link for the mini app */
startParam?: string startParam?: string
} }
| { | {
type: 'from_reply_keyboard' | 'from_switch_inline' type: 'from_reply_keyboard' | 'from_switch_inline'
/** URL of the mini app found in the button */ /** URL of the mini app found in the button */
url: string url: string
} }
| { type: 'from_side_menu' } | { type: 'from_side_menu' }
| { | {
type: 'from_inline_keyboard' | 'from_bot_menu' type: 'from_inline_keyboard' | 'from_bot_menu'
/** URL of the mini app found in the button */ /** URL of the mini app found in the button */
url: string url: string
/** /**
* If set, requests to open the mini app in compact mode (as opposed to fullview mode). * If set, requests to open the mini app in compact mode (as opposed to fullview mode).
* Must be set if the `mode` parameter of the Main Mini App link is equal to `compact`. * Must be set if the `mode` parameter of the Main Mini App link is equal to `compact`.
*/ */
compact?: boolean compact?: boolean
/** ID of the message to which we should reply if the mini app asks to do so */ /** ID of the message to which we should reply if the mini app asks to do so */
replyTo?: number | tl.TypeInputReplyTo replyTo?: number | tl.TypeInputReplyTo
/** /**
* Peer to use when sending the message. * Peer to use when sending the message.
*/ */
sendAs?: InputPeerLike sendAs?: InputPeerLike
/** If the mini app asks to send a message, whether to send it silently */ /** If the mini app asks to send a message, whether to send it silently */
silent?: boolean silent?: boolean
/** /**
* Telegram asks us to keep sending `messages.prolongWebView` requests every minute until the webview is closed. * Telegram asks us to keep sending `messages.prolongWebView` requests every minute until the webview is closed.
* If this parameter is set to `true`, the timer will not be started, and the webview will never be prolonged. * If this parameter is set to `true`, the timer will not be started, and the webview will never be prolonged.
*/ */
fireAndForget?: boolean fireAndForget?: boolean
} }
| { | {
type: 'from_attach_menu' type: 'from_attach_menu'
/** /**
* If set, requests to open the mini app in compact mode (as opposed to fullview mode). * If set, requests to open the mini app in compact mode (as opposed to fullview mode).
* Must be set if the `mode` parameter of the Main Mini App link is equal to `compact`. * Must be set if the `mode` parameter of the Main Mini App link is equal to `compact`.
*/ */
compact?: boolean compact?: boolean
/** ID of the message to which we should reply if the mini app asks to do so */ /** ID of the message to which we should reply if the mini app asks to do so */
replyTo?: number | tl.TypeInputReplyTo replyTo?: number | tl.TypeInputReplyTo
/** /**
* Peer to use when sending the message. * Peer to use when sending the message.
*/ */
sendAs?: InputPeerLike sendAs?: InputPeerLike
/** If the mini app asks to send a message, whether to send it silently */ /** If the mini app asks to send a message, whether to send it silently */
silent?: boolean silent?: boolean
/** /**
* Telegram asks us to keep sending `messages.prolongWebView` requests every minute until the webview is closed. * Telegram asks us to keep sending `messages.prolongWebView` requests every minute until the webview is closed.
* If this parameter is set to `true`, the timer will not be started, and the webview will never be prolonged. * If this parameter is set to `true`, the timer will not be started, and the webview will never be prolonged.
*/ */
fireAndForget?: boolean fireAndForget?: boolean
} }
| { | {
type: 'from_link' type: 'from_link'
/** Short name of the app (from the link) */ /** Short name of the app (from the link) */
shortName: string shortName: string
/** /**
* If the bot is asking permission to send messages to the user, * If the bot is asking permission to send messages to the user,
* whether to allow it to do so * whether to allow it to do so
*/ */
allowWrite?: boolean allowWrite?: boolean
/** /**
* If set, requests to open the mini app in compact mode (as opposed to fullview mode). * If set, requests to open the mini app in compact mode (as opposed to fullview mode).
* Must be set if the `mode` parameter of the Main Mini App link is equal to `compact`. * Must be set if the `mode` parameter of the Main Mini App link is equal to `compact`.
*/ */
compact?: boolean compact?: boolean
/** Start parameter from the deep link for the mini app */ /** Start parameter from the deep link for the mini app */
startParam?: string startParam?: string
/** /**
* Telegram asks us to keep sending `messages.prolongWebView` requests every minute until the webview is closed. * Telegram asks us to keep sending `messages.prolongWebView` requests every minute until the webview is closed.
* If this parameter is set to `true`, the timer will not be started, and the webview will never be prolonged. * If this parameter is set to `true`, the timer will not be started, and the webview will never be prolonged.
*/ */
fireAndForget?: boolean fireAndForget?: boolean
} }
/** /**
* Result of {@link openWebview} method call * Result of {@link openWebview} method call

View file

@ -177,7 +177,7 @@ export class BusinessWorkHours {
interval.startHour === 0 interval.startHour === 0
&& interval.startMinute === 0 && interval.startMinute === 0
&& ((interval.endHour === 24 && interval.endMinute === 0) && ((interval.endHour === 24 && interval.endMinute === 0)
|| (interval.endHour === 23 && interval.endMinute === 59)) || (interval.endHour === 23 && interval.endMinute === 59))
) { ) {
(day as tl.Mutable<BusinessWorkHoursDay>).is24h = true (day as tl.Mutable<BusinessWorkHoursDay>).is24h = true
} }

View file

@ -673,7 +673,7 @@ export class UpdatesManager {
msg.fwdFrom msg.fwdFrom
&& (!( && (!(
await fetchPeer(msg.fwdFrom.fromId)) await fetchPeer(msg.fwdFrom.fromId))
|| !(await fetchPeer(msg.fwdFrom.savedFromPeer, true)) || !(await fetchPeer(msg.fwdFrom.savedFromPeer, true))
) )
) { ) {
return missing return missing

View file

@ -436,12 +436,12 @@ export class Dispatcher<State extends object = never> {
this._storage this._storage
&& this._scenes && this._scenes
&& (update.name === 'new_message' && (update.name === 'new_message'
|| update.name === 'edit_message' || update.name === 'edit_message'
|| update.name === 'callback_query' || update.name === 'callback_query'
|| update.name === 'message_group' || update.name === 'message_group'
|| update.name === 'new_business_message' || update.name === 'new_business_message'
|| update.name === 'edit_business_message' || update.name === 'edit_business_message'
|| update.name === 'business_message_group') || update.name === 'business_message_group')
) { ) {
// no need to fetch scene if there are no registered scenes // no need to fetch scene if there are no registered scenes
@ -478,12 +478,12 @@ export class Dispatcher<State extends object = never> {
if ( if (
this._storage this._storage
&& (update.name === 'new_message' && (update.name === 'new_message'
|| update.name === 'edit_message' || update.name === 'edit_message'
|| update.name === 'callback_query' || update.name === 'callback_query'
|| update.name === 'message_group' || update.name === 'message_group'
|| update.name === 'new_business_message' || update.name === 'new_business_message'
|| update.name === 'edit_business_message' || update.name === 'edit_business_message'
|| update.name === 'business_message_group') || update.name === 'business_message_group')
) { ) {
if (!parsedContext) parsedContext = _parsedUpdateToContext(this._client, update) if (!parsedContext) parsedContext = _parsedUpdateToContext(this._client, update)
const key = await this._stateKeyDelegate!(parsedContext as any) const key = await this._stateKeyDelegate!(parsedContext as any)

View file

@ -15,15 +15,15 @@ import {
} from '@mtcute/core' } from '@mtcute/core'
type UpdatesWithText = type UpdatesWithText =
| Message | Message
| BusinessMessage | BusinessMessage
| UpdateContextDistributed< | UpdateContextDistributed<
| InlineQuery | InlineQuery
| ChosenInlineResult | ChosenInlineResult
| CallbackQuery | CallbackQuery
| InlineCallbackQuery | InlineCallbackQuery
| BusinessCallbackQuery | BusinessCallbackQuery
> >
function extractText(obj: UpdatesWithText): string | null { function extractText(obj: UpdatesWithText): string | null {
if (obj instanceof Message || obj instanceof BusinessMessage) { if (obj instanceof Message || obj instanceof BusinessMessage) {

View file

@ -251,8 +251,8 @@ function fromPersistentIdV23(binary: Uint8Array, version: number): td.RawFullRem
if ( if (
location.source.fileType !== fileType location.source.fileType !== fileType
|| (fileType !== td.FileType.Photo || (fileType !== td.FileType.Photo
&& fileType !== td.FileType.Thumbnail && fileType !== td.FileType.Thumbnail
&& fileType !== td.FileType.EncryptedThumbnail) && fileType !== td.FileType.EncryptedThumbnail)
) { ) {
throw new td.InvalidFileIdError('Invalid FileType in PhotoRemoteFileLocation Thumbnail') throw new td.InvalidFileIdError('Invalid FileType in PhotoRemoteFileLocation Thumbnail')
} }

File diff suppressed because it is too large Load diff

View file

@ -1,3 +1,3 @@
packages: packages:
- packages/* - packages/*
- '!e2e/*' - e2e

View file

@ -32,7 +32,6 @@
"skipLibCheck": true "skipLibCheck": true
}, },
"exclude": [ "exclude": [
"e2e/",
"**/node_modules", "**/node_modules",
"**/private", "**/private",
"**/__snapshots__" "**/__snapshots__"