[JavaScript, Программирование, Тестирование веб-сервисов] Тестирование с использованием Puppeteer
Автор
Сообщение
news_bot ®
Стаж: 6 лет 9 месяцев
Сообщений: 27286
Любите ли вы тесты, как люблю их я: всеми фибрами души, со всей страстью и энтузиазмом, на которые только способен разработчик, жадный до полного покрытия кода?
В этой статье я расскажу о тестировании кода с помощью Puppeteer — сервиса, который позволяет проверять работу скриптов в их естественной среде обитания — в браузере. Это не полноценный туториал по Puppeteer, а скорее набор советов о том, как писать осмысленные и стабильно работающие тесты.
Но сначала немного о том, для чего использую Puppeteer я.
Я работаю в команде, которая занимается разработкой трекинговых решений, и неотъемлемой частью моей работы является покрытие новых функций тестами. Тесты бывают разные: от простых unit-тестов до масштабного интеграционного тестирования. От вида тестов зависит и окружение, в котором они будут запускаться.
Зачем вообще писать тесты?
Каждый раз, когда я пишу тесты, они помогают обнаружить баги. Но у тестирования есть и другие плюсы.
Один из них — обнаружение подводных камней. Подготовка тест-кейсов позволяет сложить в голове целостную картину бизнес-логики и того, как она должна работать. Тесты заставляют еще раз всё обдумать, и, быть может, это-то и спасет вас от неожиданных проблем на проде.
Кроме того, хорошие тесты — это вторая документация (а если ее нет, то единственная). По ним можно понять, какое поведение ожидается от кода, а не как оно достигнуто. Поэтому не стоит усложнять тесты и нагромождать их utils-функциями (а тем более писать тесты на тесты).
И конечно же, не стоит забывать, что тесты — это весело! Для их написания используется множество библиотек, и у каждой из них своя специфика, так что в процессе подготовки тестов возникает не меньше интересных задач.
Тестирование с использованием Puppeteer
Так как это не туториал, а набор советов по решению проблем, с которыми я столкнулся, здесь не будет информации, как установить нужные библиотеки и начать работу. С этим вам поможет справиться репозиторий Puppeteer и поисковая строка этого интернет-ресурса.
Весь код, связанный с этой статьей, доступен в репозитории puppeteer-showcase.
Запуск тестов
В ходе работы скриптов и тестов могут появляться побочные эффекты: записываться cookie, добавляться записи в localStorage и т. д., поэтому каждый тест надо запускать с чистого листа — в новом окне браузера.
В этих тестах используется фреймворк Mocha
describe('Puppeteer test cases', () => {
// Перед каждым тестом запускаем браузер и переходим на тестовую страничку (127.0.0.1:5000)
beforeEach(async () => {
this.browser = await puppeteer.launch({
// Для тестов не обязательно видеть UI браузера, поэтому запускаем его в headless режиме.
headless: true,
});
this.page = await this.browser.newPage();
await this.page.goto('http://127.0.0.1:5000/');
});
// Не забываем закрывать открытый браузер
afterEach(async () => {
await this.browser.close();
});
// Наши тесты будут здесь
});
Это достаточно очевидный, но очень важный момент: все написанные тесты должны быть независимыми и работать без опоры друг на друга. Писать связанные тесты — плохо!
Вы ещё используете timeout? Тогда мы идём к вам
Предположим, у нас есть кнопка (#button1), при нажатии на которую выполняется следующий код:
let redirectUrl = 'https://example.com/default/url/';
try {
const response = await fetch('https://example.com/api/some/endpoint/?with=params');
redirectUrl = await response.json();
} catch (exc) {
console.log(exc);
}
window.location = redirectUrl;
Этот скрипт направляет пользователя на URL, полученный от сервера. В случае ошибки скрипт логирует ее и направляет пользователя на URL по умолчанию.
В голову приходит несколько сценариев для тестирования:
- положительный — API повел себя как ожидалось и вернул redirectUrl;
- отрицательный — API недоступен, запрос не завершился;
- отрицательный — API вернул неверный ответ (например, не JSON).
Опишем их:
Тесты под катом
SPL
describe('button1 test cases', () => {
it('should follow returned redirectUrl if response is ok', async () => {
this.page.on('request', (request) => {
if (request.url().endsWith('/api/some/endpoint/?with=params')) {
request.respond({
status: 200,
contentType: 'application/json',
body: JSON.stringify('https://example.com/returned/redirect/url/'),
});
} else {
request.continue();
}
});
this.page.setRequestInterception(true);
this.page.click('#button1');
await new Promise(resolve => setTimeout(resolve, 100));
expect(this.page.url()).to.equal('https://example.com/returned/redirect/url/');
});
it('should follow default url if request is blocked', async () => {
this.page.on('request', (request) => {
if (request.url().endsWith('/api/some/endpoint/?with=params')) {
request.abort('blockedbyclient');
} else {
request.continue();
}
});
this.page.setRequestInterception(true);
this.page.click('#button1');
await new Promise(resolve => setTimeout(resolve, 100));
expect(this.page.url()).to.equal('https://example.com/default/url/');
});
it('should follow default url if request is invalid', async () => {
this.page.on('request', (request) => {
if (request.url().endsWith('/api/some/endpoint/?with=params')) {
request.respond({
status: 500,
contentType: 'text/html',
body: '<p>Error</p>',
});
} else {
request.continue();
}
});
this.page.setRequestInterception(true);
this.page.click('#button1');
await new Promise(resolve => setTimeout(resolve, 100));
expect(this.page.url()).to.equal('https://example.com/default/url/');
});
});
В этих тестах используется библиотека Chai
Кода получилось много, как это часто бывает с тестами. Посмотрим внимательно на первый из них:
it('should follow returned redirectUrl if response is ok', async () => {
// Перехватываем все запросы со страницы
this.page.on('request', (request) => {
// Если это запрос к API, то возвращаем 200 ответ с redirectUrl в JSON
if (request.url().endsWith('/api/some/endpoint/?with=params')) {
request.respond({
status: 200,
contentType: 'application/json',
body: JSON.stringify('https://example.com/returned/redirect/url/'),
});
} else {
// Если это запрос не к API, то не пропускаем запрос
request.continue();
}
});
this.page.setRequestInterception(true);
// Кликаем по кнопке
this.page.click('#button1');
// Ждем 100 мс, пока пройдет запрос и сменится страница
await new Promise(resolve => setTimeout(resolve, 100));
// Проверяем, что мы попали на нужную страницу
expect(this.page.url()).to.equal('https://example.com/returned/redirect/url/');
});
Пайплайн такой:
- нажимаем на кнопку;
- перехватываем запрос к API и возвращаем нужный redirectUrl;
- ждем 100 мс;
- проверяем, попали ли мы на нужную страницу.
Постойте, а что, если скрипт не успеет совершить запрос и перенаправить пользователя за 100 мс? Может быть, увеличить таймаут до 1 с? А что, если и за секунду не успеет? Да и если каждый тест будет ждать по секунде, сколько же будут работать все тесты?
Ответ прост: не используйте таймауты. У Puppeteer API есть множество методов, которые позволяют избежать таймаутов, дождавшись вместо этого совершения какого-либо действия. В нашем случае подойдет waitForNavigation. Этот метод ожидает, пока не произойдет смена страницы.
Хороший тест будет выглядеть так:
it('should follow returned redirectUrl if response is ok', async () => {
// Перехватываем все запросы со страницы
this.page.on('request', (request) => {
// Если это запрос к API, то возвращает 200 ответ с redirectUrl в JSON
if (request.url().endsWith('/api/some/endpoint/?with=params')) {
request.respond({
status: 200,
contentType: 'application/json',
body: JSON.stringify('https://example.com/returned/redirect/url/'),
});
} else {
// Если это запрос не к API, то не трогаем запрос
request.continue();
}
});
this.page.setRequestInterception(true);
// Кликаем по кнопке
this.page.click('#button1');
// Ждем, пока сменится страница
await this.page.waitForNavigation();
// Проверяем, что мы попали на нужную страницу
expect(this.page.url()).to.equal('https://example.com/returned/redirect/url/');
});
И никаких таймаутов!
Это очень простой пример, но от того не менее показательный. Для того чтобы выйти победителем из битвы с таймаутами, необходимо продумывать очередность await-ов: сначала действие, затем его ожидание. Тогда и тесты ускорятся, и CI не будет падать, когда не хватит таймаута.
Среда выполнения тестов
Есть две независимые среды выполнения JavaScript-кода при написании тестов с Puppeteer:
- Node, при помощи которого были запущены тесты, — в нем исполняется код тестов;
- браузер, который был открыт Puppeteer, — в нем исполняется код, связанный с открытой страницей, — бизнес-логика.
Эти две среды изолированы, у них нет общей области видимости. Однако при помощи API Puppeteer может «общаться» с браузером. Также с браузером взаимодействуют и тесты, в том числе выполняют код и получают его результат через evaluate.
Понимание того, что бизнес-логика и тесты для нее исполняются в двух разных изолированных средах, между которыми есть только один мостик в виде Puppeteer, является критичным.
Лучше картинки с котиком это может продемонстрировать только тест.
Тестируем console.log
На этот раз бизнес-логика будет выглядеть чуть проще:
console.log('Hello from main.js!');
и будет запускаться при нажатии на кнопку #button2.
Тогда напишем два теста:
- первый будет проверять, что при нажатии на кнопку в консоль окружения тестов ничего не пишется;
- второй — что при нажатии на кнопку в консоль браузера пишется «Hello from main.js!».
А вот и тесты:
describe('button2 test cases', () => {
it('should not print message to node console on button2 click', async () => {
const printedMessages = [];
// Подменяем console.log на функцию-шпиона и записываем логируемые сообщения
console.log = (message) => {
printedMessages.push(message);
}
// Нажимаем на кнопку, которая печатает в консоль
await this.page.click('#button2');
// Проверяем, что в консоль ничего не было написано
expect(printedMessages).to.be.empty;
});
it('should print message to browser console on button2 click', async () => {
// Подменяем браузерный console.log на функцию-шпиона и записываем логируемые сообщения
await this.page.evaluate(() => {
window.printedMessages = [];
window.console.log = (message) => {
window.printedMessages.push(message);
}
});
// Нажимаем на кнопку, которая печатает в консоль
await this.page.click('#button2');
// Извлекаем шпионские данные от подмененного console.log
const printedMessages = await this.page.evaluate(() => window.printedMessages);
// Проверяем, что в консоль было написано ожидаемое сообщение
expect(printedMessages).to.contain('Hello from main.js!');
});
});
Пайплайн тут следующий:
- подменяем console.log (тестовый либо браузерный);
- нажимаем на кнопку 2;
- проверяем, какие сообщения были написаны через console.log.
Запускаем тесты и видим заветную картину:
should not print message to node console on button2 click и should print message to browser console on button2 click прошли, значит, я никого не обманул.
Подсматриваем за Puppeteer
Еще одна прелесть тестов с использованием Puppeteer — их наглядность. Помните, как мы при запуске браузера указывали параметры?
this.browser = await puppeteer.launch({
headless: true, // <-- параметр
});
Мы использовали headless: true потому, что тесты так проходят (или падают) быстрее, так как не нужно тратить ресурсы на запуск графической оболочки браузера.
Однако если указать следующие параметры:
this.browser = await puppeteer.launch({
headless: false, // <-- запускаем графическую оболочку
slowMo: 500, // <-- включаем задержку между действиями в 500 мс
});
и запустить тесты, мы сможем понаблюдать за тем, что делает Puppeteer по нашим указаниям:
Заключение
В этой статье я поделился небольшой частью интересностей Puppeteer, оставив за кадром его преимущества вроде кроссбраузерности (с некоторыми оговорками) и непростые задачи, которые приходилось решать для повышения стабильности тестов. Если мое повествование нашло отклик в вашей душе, напишите об этом в комментариях.
Еще не могу не посоветовать статью моего коллеги о том, какой путь он проделал для того, чтобы попасть на стажировку и успешно ее пройти. Если вы устали от разноцветного кода, советую ее прочесть: там есть смешные картинки.
Спасибо за внимание!
исходный код
===========
Источник:
habr.com
===========
Похожие новости:
- [Программирование, Управление разработкой, Управление персоналом] Как начать программировать в парах
- [Семантика, Программирование, Prolog, Бизнес-модели] Проектируем мультипарадигменный язык программирования. Часть 6 — Заимствования из SQL
- [Программирование, Разработка мобильных приложений, Dart, Flutter] Работа с асинхронностью в Dart
- [Программирование, Машинное обучение] Распознавание речи с помощью инструментов машинного обучения
- [Системное администрирование, Программирование, IT-инфраструктура, DevOps] Создание современных процессов CI/CD для бессерверных приложений с Red Hat OpenShift Pipelines и Argo CD. Часть 2 (перевод)
- [Программирование, Java, Микросервисы] Spring Cloud и Spring Boot. Часть 1: использование Eureka Server (перевод)
- [Программирование микроконтроллеров, Компьютерное железо, DIY или Сделай сам] Raspberry Pi Pico на МК RP2040: начало и первые шаги. Что есть поесть за $4
- [Тестирование веб-сервисов] Тестирование нескольких экземпляров одного и того же мок компонента (перевод)
- [Разработка веб-сайтов, JavaScript, Программирование, ReactJS] Почему Context — это не инструмент «управления состоянием» (перевод)
- [Занимательные задачки, Программирование, Управление разработкой, Удалённая работа] Как отсеивать плохих программистов. 10 лучших автоматических инструментов проверки качества кода
Теги для поиска: #_javascript, #_programmirovanie (Программирование), #_testirovanie_vebservisov (Тестирование веб-сервисов), #_puppeteer, #_javascript, #_unit_testing, #_admitad, #_blog_kompanii_admitad (
Блог компании Admitad
), #_javascript, #_programmirovanie (
Программирование
), #_testirovanie_vebservisov (
Тестирование веб-сервисов
)
Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Текущее время: 25-Ноя 08:17
Часовой пояс: UTC + 5
Автор | Сообщение |
---|---|
news_bot ®
Стаж: 6 лет 9 месяцев |
|
Любите ли вы тесты, как люблю их я: всеми фибрами души, со всей страстью и энтузиазмом, на которые только способен разработчик, жадный до полного покрытия кода? В этой статье я расскажу о тестировании кода с помощью Puppeteer — сервиса, который позволяет проверять работу скриптов в их естественной среде обитания — в браузере. Это не полноценный туториал по Puppeteer, а скорее набор советов о том, как писать осмысленные и стабильно работающие тесты. Но сначала немного о том, для чего использую Puppeteer я. Я работаю в команде, которая занимается разработкой трекинговых решений, и неотъемлемой частью моей работы является покрытие новых функций тестами. Тесты бывают разные: от простых unit-тестов до масштабного интеграционного тестирования. От вида тестов зависит и окружение, в котором они будут запускаться. Зачем вообще писать тесты? Каждый раз, когда я пишу тесты, они помогают обнаружить баги. Но у тестирования есть и другие плюсы. Один из них — обнаружение подводных камней. Подготовка тест-кейсов позволяет сложить в голове целостную картину бизнес-логики и того, как она должна работать. Тесты заставляют еще раз всё обдумать, и, быть может, это-то и спасет вас от неожиданных проблем на проде. Кроме того, хорошие тесты — это вторая документация (а если ее нет, то единственная). По ним можно понять, какое поведение ожидается от кода, а не как оно достигнуто. Поэтому не стоит усложнять тесты и нагромождать их utils-функциями (а тем более писать тесты на тесты). И конечно же, не стоит забывать, что тесты — это весело! Для их написания используется множество библиотек, и у каждой из них своя специфика, так что в процессе подготовки тестов возникает не меньше интересных задач. Тестирование с использованием Puppeteer Так как это не туториал, а набор советов по решению проблем, с которыми я столкнулся, здесь не будет информации, как установить нужные библиотеки и начать работу. С этим вам поможет справиться репозиторий Puppeteer и поисковая строка этого интернет-ресурса. Весь код, связанный с этой статьей, доступен в репозитории puppeteer-showcase. Запуск тестов В ходе работы скриптов и тестов могут появляться побочные эффекты: записываться cookie, добавляться записи в localStorage и т. д., поэтому каждый тест надо запускать с чистого листа — в новом окне браузера. В этих тестах используется фреймворк Mocha
describe('Puppeteer test cases', () => {
// Перед каждым тестом запускаем браузер и переходим на тестовую страничку (127.0.0.1:5000) beforeEach(async () => { this.browser = await puppeteer.launch({ // Для тестов не обязательно видеть UI браузера, поэтому запускаем его в headless режиме. headless: true, }); this.page = await this.browser.newPage(); await this.page.goto('http://127.0.0.1:5000/'); }); // Не забываем закрывать открытый браузер afterEach(async () => { await this.browser.close(); }); // Наши тесты будут здесь }); Это достаточно очевидный, но очень важный момент: все написанные тесты должны быть независимыми и работать без опоры друг на друга. Писать связанные тесты — плохо! Вы ещё используете timeout? Тогда мы идём к вам Предположим, у нас есть кнопка (#button1), при нажатии на которую выполняется следующий код: let redirectUrl = 'https://example.com/default/url/';
try { const response = await fetch('https://example.com/api/some/endpoint/?with=params'); redirectUrl = await response.json(); } catch (exc) { console.log(exc); } window.location = redirectUrl; Этот скрипт направляет пользователя на URL, полученный от сервера. В случае ошибки скрипт логирует ее и направляет пользователя на URL по умолчанию. В голову приходит несколько сценариев для тестирования:
Опишем их: Тесты под катомSPLdescribe('button1 test cases', () => {
it('should follow returned redirectUrl if response is ok', async () => { this.page.on('request', (request) => { if (request.url().endsWith('/api/some/endpoint/?with=params')) { request.respond({ status: 200, contentType: 'application/json', body: JSON.stringify('https://example.com/returned/redirect/url/'), }); } else { request.continue(); } }); this.page.setRequestInterception(true); this.page.click('#button1'); await new Promise(resolve => setTimeout(resolve, 100)); expect(this.page.url()).to.equal('https://example.com/returned/redirect/url/'); }); it('should follow default url if request is blocked', async () => { this.page.on('request', (request) => { if (request.url().endsWith('/api/some/endpoint/?with=params')) { request.abort('blockedbyclient'); } else { request.continue(); } }); this.page.setRequestInterception(true); this.page.click('#button1'); await new Promise(resolve => setTimeout(resolve, 100)); expect(this.page.url()).to.equal('https://example.com/default/url/'); }); it('should follow default url if request is invalid', async () => { this.page.on('request', (request) => { if (request.url().endsWith('/api/some/endpoint/?with=params')) { request.respond({ status: 500, contentType: 'text/html', body: '<p>Error</p>', }); } else { request.continue(); } }); this.page.setRequestInterception(true); this.page.click('#button1'); await new Promise(resolve => setTimeout(resolve, 100)); expect(this.page.url()).to.equal('https://example.com/default/url/'); }); }); В этих тестах используется библиотека Chai
it('should follow returned redirectUrl if response is ok', async () => {
// Перехватываем все запросы со страницы this.page.on('request', (request) => { // Если это запрос к API, то возвращаем 200 ответ с redirectUrl в JSON if (request.url().endsWith('/api/some/endpoint/?with=params')) { request.respond({ status: 200, contentType: 'application/json', body: JSON.stringify('https://example.com/returned/redirect/url/'), }); } else { // Если это запрос не к API, то не пропускаем запрос request.continue(); } }); this.page.setRequestInterception(true); // Кликаем по кнопке this.page.click('#button1'); // Ждем 100 мс, пока пройдет запрос и сменится страница await new Promise(resolve => setTimeout(resolve, 100)); // Проверяем, что мы попали на нужную страницу expect(this.page.url()).to.equal('https://example.com/returned/redirect/url/'); }); Пайплайн такой:
Постойте, а что, если скрипт не успеет совершить запрос и перенаправить пользователя за 100 мс? Может быть, увеличить таймаут до 1 с? А что, если и за секунду не успеет? Да и если каждый тест будет ждать по секунде, сколько же будут работать все тесты? Ответ прост: не используйте таймауты. У Puppeteer API есть множество методов, которые позволяют избежать таймаутов, дождавшись вместо этого совершения какого-либо действия. В нашем случае подойдет waitForNavigation. Этот метод ожидает, пока не произойдет смена страницы. Хороший тест будет выглядеть так: it('should follow returned redirectUrl if response is ok', async () => {
// Перехватываем все запросы со страницы this.page.on('request', (request) => { // Если это запрос к API, то возвращает 200 ответ с redirectUrl в JSON if (request.url().endsWith('/api/some/endpoint/?with=params')) { request.respond({ status: 200, contentType: 'application/json', body: JSON.stringify('https://example.com/returned/redirect/url/'), }); } else { // Если это запрос не к API, то не трогаем запрос request.continue(); } }); this.page.setRequestInterception(true); // Кликаем по кнопке this.page.click('#button1'); // Ждем, пока сменится страница await this.page.waitForNavigation(); // Проверяем, что мы попали на нужную страницу expect(this.page.url()).to.equal('https://example.com/returned/redirect/url/'); }); И никаких таймаутов! Это очень простой пример, но от того не менее показательный. Для того чтобы выйти победителем из битвы с таймаутами, необходимо продумывать очередность await-ов: сначала действие, затем его ожидание. Тогда и тесты ускорятся, и CI не будет падать, когда не хватит таймаута. Среда выполнения тестов Есть две независимые среды выполнения JavaScript-кода при написании тестов с Puppeteer:
Эти две среды изолированы, у них нет общей области видимости. Однако при помощи API Puppeteer может «общаться» с браузером. Также с браузером взаимодействуют и тесты, в том числе выполняют код и получают его результат через evaluate. Понимание того, что бизнес-логика и тесты для нее исполняются в двух разных изолированных средах, между которыми есть только один мостик в виде Puppeteer, является критичным. Лучше картинки с котиком это может продемонстрировать только тест. Тестируем console.log На этот раз бизнес-логика будет выглядеть чуть проще: console.log('Hello from main.js!');
и будет запускаться при нажатии на кнопку #button2. Тогда напишем два теста:
А вот и тесты: describe('button2 test cases', () => {
it('should not print message to node console on button2 click', async () => { const printedMessages = []; // Подменяем console.log на функцию-шпиона и записываем логируемые сообщения console.log = (message) => { printedMessages.push(message); } // Нажимаем на кнопку, которая печатает в консоль await this.page.click('#button2'); // Проверяем, что в консоль ничего не было написано expect(printedMessages).to.be.empty; }); it('should print message to browser console on button2 click', async () => { // Подменяем браузерный console.log на функцию-шпиона и записываем логируемые сообщения await this.page.evaluate(() => { window.printedMessages = []; window.console.log = (message) => { window.printedMessages.push(message); } }); // Нажимаем на кнопку, которая печатает в консоль await this.page.click('#button2'); // Извлекаем шпионские данные от подмененного console.log const printedMessages = await this.page.evaluate(() => window.printedMessages); // Проверяем, что в консоль было написано ожидаемое сообщение expect(printedMessages).to.contain('Hello from main.js!'); }); }); Пайплайн тут следующий:
Запускаем тесты и видим заветную картину: should not print message to node console on button2 click и should print message to browser console on button2 click прошли, значит, я никого не обманул. Подсматриваем за Puppeteer Еще одна прелесть тестов с использованием Puppeteer — их наглядность. Помните, как мы при запуске браузера указывали параметры? this.browser = await puppeteer.launch({
headless: true, // <-- параметр }); Мы использовали headless: true потому, что тесты так проходят (или падают) быстрее, так как не нужно тратить ресурсы на запуск графической оболочки браузера. Однако если указать следующие параметры: this.browser = await puppeteer.launch({
headless: false, // <-- запускаем графическую оболочку slowMo: 500, // <-- включаем задержку между действиями в 500 мс }); и запустить тесты, мы сможем понаблюдать за тем, что делает Puppeteer по нашим указаниям: Заключение В этой статье я поделился небольшой частью интересностей Puppeteer, оставив за кадром его преимущества вроде кроссбраузерности (с некоторыми оговорками) и непростые задачи, которые приходилось решать для повышения стабильности тестов. Если мое повествование нашло отклик в вашей душе, напишите об этом в комментариях. Еще не могу не посоветовать статью моего коллеги о том, какой путь он проделал для того, чтобы попасть на стажировку и успешно ее пройти. Если вы устали от разноцветного кода, советую ее прочесть: там есть смешные картинки. Спасибо за внимание! исходный код =========== Источник: habr.com =========== Похожие новости:
Блог компании Admitad ), #_javascript, #_programmirovanie ( Программирование ), #_testirovanie_vebservisov ( Тестирование веб-сервисов ) |
|
Вы не можете начинать темы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Вы не можете отвечать на сообщения
Вы не можете редактировать свои сообщения
Вы не можете удалять свои сообщения
Вы не можете голосовать в опросах
Вы не можете прикреплять файлы к сообщениям
Вы не можете скачивать файлы
Текущее время: 25-Ноя 08:17
Часовой пояс: UTC + 5