Click here to Skip to main content
14,976,314 members
Articles / DevOps / Automation
Article
Posted 1 Dec 2016

Stats

150K views
992 downloads
94 bookmarked

Atata - C# Web Test Automation Framework

Rate me:
Please Sign up or sign in to vote.
4.87/5 (46 votes)
4 Mar 2021CPOL8 min read
An introduction to Atata C#/.NET web UI test automation full-featured framework based on Selenium WebDriver
In this article, you will learn about a C# web test automation Framework called Atata.

Introduction

Atata Framework - C#/.NET web test automation full featured framework based on Selenium WebDriver. It uses fluent page object pattern; has unique logging system; contains triggers functionality; has a set of ready to use components. Supports .NET Framework 4.0+ and .NET Core/Standard 2.0+.

The Framework basically consists of the following concepts:

  • Components (controls and page objects)
  • Attributes of the control search
  • Settings attributes
  • Triggers
  • Verification attributes and methods

Features

  • WebDriver. Based on Selenium WebDriver and preserves all its features.
  • Page Object Model. Provides unique fluent page object pattern that is easy to implement and maintain.
  • Components. Contains a rich set of ready to use components for inputs, tables, lists, etc.
  • Integration. Works on any .NET test engine (e.g. NUnit, xUnit, SpecFlow) as well as on CI systems like Jenkins, Azure DevOps or TeamCity.
  • Triggers. A bunch of triggers to bind with different events to extend component behavior.
  • Verification. A set of fluent assertion methods and triggers for the component and data verification.
  • Configurable. Defines the default component search strategies as well as additional settings. Atata.Configuration.Json provides flexible JSON configurations.
  • Reporting/Logging. Built-in customizable logging and screenshot capturing functionality.
  • Extensible. Atata.Bootstrap and Atata.KendoUI packages have a set of ready to use components. Framework supports any kind of extending.

Background

An idea of the Atata Framework is to create complex, extensible and customizable web test automation framework for any kind of websites using Selenium WebDriver and C#/.NET.

References

A list of links related to the framework:

To install it from NuGet Package Manager Console, run Install-Package Atata

Usage

I would like to show the usage of the framework using the demo website. It is a simple website that contains the following: Sign In page, Users page, User Details page and User Edit window.

The test project will use the NuGet packages: Atata, Atata.Bootstrap, Atata.WebDriverSetup, Selenium.WebDriver, NUnit and NUnit3TestAdapter.

I use NUnit but it is not required, you can use any .NET testing framework like MSTest or xUnit. But for me, NUnit fits the best.

Let's try to implement an auto-test for the following test case:

  1. Sign in on https://demo.atata.io/signin page.
  2. Click "New" button on the user list page.
  3. Create a new user.
  4. Verify that the new user is present on the user list page.
  5. Navigate to the user's details.
  6. Verify the user's details.

Any page can be represented with the page object. I will try to explain the Atata's stuff step by step. To start, we need to implement the page object class for Sign In page.

Sign In Page

Sign In page

C#
using Atata;

namespace SampleApp.UITests
{
    using _ = SignInPage;

    [Url("signin")]
    [VerifyTitle]
    [VerifyH1]
    public class SignInPage : Page<_>
    {
        public TextInput<_> Email { get; private set; }

        public PasswordInput<_> Password { get; private set; }

        public Button<UsersPage, _> SignIn { get; private set; }
    }
}
SignInPage.cs

In Atata, you operate with controls, rather than IWebElement's. The page object consists of the controls. Any control like TextInput wraps the IWebElement and has its own set of methods and properties for the interaction with it. Find out more about the components in the documentation.

Please note the 5th line of the above code:

C#
using _ = SignInPage;

It is made to simplify the use of class type for the declaration of the controls, as every control has to know its owner page object (specify single or last generic argument). It's just a syntactic sugar and, of course, you can declare the controls this way:

C#
public TextInput<SignInPage> Email { get; private set; }

SignIn button, as you can see, is defined with 2 generic arguments: the first one is the type of the page object to navigate to, after the button is clicked; the other one is the owner type. For the buttons and links that don't perform any navigation, just pass single generic argument, the owner page object.

It is possible to mark the properties with the attributes to specify the finding approach (e.g. FindById, FindByName). In the current case, it is not needed, as the default search for inputs is FindByLabel and for buttons is FindByContentOrValue, and it suits our needs. Find out more about the control search in the documentation.

There is also [Url] attribute which specifies relative (can be absolute) URL of this page. It can be used when you navigate to this page object.

[VerifyTitle] and [VerifyH1] are the triggers that, in the current case, are executed upon the page object initialization (after the navigation to the page). If the string value is not passed to these attributes, they use class name without the "Page" ending in title case, as "Sign In". It can be totally configured. Find out more about the triggers in the documentation.

Users Page

Users page

The Users page contains the table of the users with CRUD actions.

C#
using Atata;

namespace SampleApp.UITests
{
    using _ = UsersPage;

    [VerifyTitle]
    [VerifyH1]
    public class UsersPage : Page<_>
    {
        public Button<UserEditWindow, _> New { get; private set; }

        public Table<UserTableRow, _> Users { get; private set; }

        public class UserTableRow : TableRow<_>
        {
            public Text<_> FirstName { get; private set; }

            public Text<_> LastName { get; private set; }

            public Text<_> Email { get; private set; }

            public Content<Office, _> Office { get; private set; }

            public Link<UserDetailsPage, _> View { get; private set; }

            public Button<UserEditWindow, _> Edit { get; private set; }

            [CloseConfirmBox]
            public Button<_> Delete { get; private set; }
        }
    }
}
UsersPage.cs

In the UsersPage class, you can see the usage of Table<TRow, TOwner> and TableRow<TOwner> controls. In UserTableRow class, the properties of type Text and Content by default are being searched by the column header (FindByColumnHeader attribute). It can also be configured. For example, the FirstName control will contain "John" value for the first row. The usage of the table will be shown in the test method below.

Delete button is marked with CloseConfirmBox trigger which accepts the confirmation window shown after the click on the button.

User Create/Edit Window

User Create/Edit window

It is quite a simple Bootstrap popup window with two tabs and regular input controls.

C#
using Atata;
using Atata.Bootstrap;

namespace SampleApp.UITests
{
    using _ = UserEditWindow;

    public class UserEditWindow : BSModal<_>
    {
        [FindById]
        public GeneralTabPane General { get; private set; }

        [FindById]
        public AdditionalTabPane Additional { get; private set; }

        [Term("Save", "Create")]
        public Button<UsersPage, _> Save { get; private set; }

        public class GeneralTabPane : BSTabPane<_>
        {
            public TextInput<_> FirstName { get; private set; }

            public TextInput<_> LastName { get; private set; }

            [RandomizeStringSettings("{0}@mail.com")]
            public TextInput<_> Email { get; private set; }

            public Select<Office?, _> Office { get; private set; }

            [FindByName]
            public RadioButtonList<Gender?, _> Gender { get; private set; }
        }

        public class AdditionalTabPane : BSTabPane<_>
        {
            public DateInput<_> Birthday { get; private set; }

            public TextArea<_> Notes { get; private set; }
        }
    }
}
UserEditWindow.cs

The UserEditWindow is inherited from BSModal<TOwner> page object class. It is a component of Atata.Bootstrap package.

Save button is marked with Term("Save", "Create") attribute that specifies the values for the control search. It means that the button should be found by "Save" or "Cancel" content.

Gender and Office controls use the following enums:

C#
namespace SampleApp.UITests
{
    public enum Gender
    {
        Male,
        Female
    }
}
Gender.cs
C#
namespace SampleApp.UITests
{
    public enum Office
    {
        Berlin,
        London,
        NewYork,
        Paris,
        Rome,
        Tokio,
        Washington
    }
}
Office.cs

User Details Page

User Details page

C#
using System;
using Atata;

namespace SampleApp.UITests
{
    using _ = UserDetailsPage;

    public class UserDetailsPage : Page<_>
    {
        [FindFirst]
        public H1<_> Header { get; private set; }

        [FindByDescriptionTerm]
        public Text<_> Email { get; private set; }

        [FindByDescriptionTerm]
        public Content<Office, _> Office { get; private set; }

        [FindByDescriptionTerm]
        public Content<Gender, _> Gender { get; private set; }

        [FindByDescriptionTerm]
        public Content<DateTime?, _> Birthday { get; private set; }

        [FindByDescriptionTerm]
        public Text<_> Notes { get; private set; }
    }
}
UserDetailsPage.cs

Atata Setup

The best place to configure Atata is a global set-up method that is executed once before all test.

C#
using Atata;
using NUnit.Framework;

namespace SampleApp.UITests
{
    [SetUpFixture]
    public class SetUpFixture
    {
        [OneTimeSetUp]
        public void GlobalSetUp()
        {
            AtataContext.GlobalConfiguration
                .UseChrome()
                    .WithArguments("start-maximized")
                .UseBaseUrl("https://demo.atata.io/")
                .UseCulture("en-US")
                .UseAllNUnitFeatures()
                .Attributes.Global.Add(
                    new VerifyTitleSettingsAttribute { Format = "{0} - Atata Sample App" });

            AtataContext.GlobalConfiguration.AutoSetUpDriverToUse();
        }
    }
}
SetUpFixture.cs

Here we globally configure Atata with the following:

  1. Tell to use Chrome browser.
  2. Set the base site URL.
  3. Set the culture, which is used by the controls like DateInput.
  4. Tell to use all Atata features for integration with NUnit, like logging to NUnit TestContext, taking screenshot on test failure, etc.
  5. Set format of the page title, as all the pages on the testing website have a page title like "Sign In - Atata Sample App".
  6. AutoSetUpDriverToUse sets up driver for the browser that we want to use, which is chromedriver.exe in this case. Atata.WebDriverSetup package is responsible for that.

For more configuration options, please check the Getting Started / Set Up page in the docs.

Base UITestFixture Class

Now let's configure NUnit to build AtataContext (start browser and do extra configuration) on test set up event and cleanup Atata (close browser, etc.) on test tear down event. We can create base test fixture class that will do that. Also we can put reusable Login method there.

C#
using Atata;
using NUnit.Framework;

namespace SampleApp.UITests
{
    [TestFixture]
    public class UITestFixture
    {
        [SetUp]
        public void SetUp()
        {
            AtataContext.Configure().Build();
        }

        [TearDown]
        public void TearDown()
        {
            AtataContext.Current?.CleanUp();
        }

        protected UsersPage Login()
        {
            return Go.To<SignInPage>()
                .Email.Set("admin@mail.com")
                .Password.Set("abc123")
                .SignIn.ClickAndGo();
        }
    }
}
UITestFixture.cs

Here you can see a primitive usage of AtataContext Build and CleanUp methods.

As you can see in Login method, navigation starts from Go static class. To keep the example simple, I use hard-coded credentials here, that can easily be moved to App.config or Atata.json, for example.

User Test

And finally, the test that will use all of the created above classes and enums.

C#
using Atata;
using NUnit.Framework;

namespace SampleApp.UITests
{
    public class UserTests : UITestFixture
    {
        [Test]
        public void Create()
        {
            Office office = Office.NewYork;
            Gender gender = Gender.Male;

            Login() // Returns UsersPage.
                .New.ClickAndGo() // Returns UserEditWindow.
                    .ModalTitle.Should.Equal("New User")
                    .General.FirstName.SetRandom(out string firstName)
                    .General.LastName.SetRandom(out string lastName)
                    .General.Email.SetRandom(out string email)
                    .General.Office.Set(office)
                    .General.Gender.Set(gender)
                    .Save.ClickAndGo() // Returns UsersPage.
                .Users.Rows[x => x.Email == email].View.ClickAndGo() // Returns UserDetailsPage.
                    .AggregateAssert(page => page
                        .Header.Should.Equal($"{firstName} {lastName}")
                        .Email.Should.Equal(email)
                        .Office.Should.Equal(office)
                        .Gender.Should.Equal(gender)
                        .Birthday.Should.Not.Exist()
                        .Notes.Should.Not.Exist());
        }
    }
}
UserTests.cs

I prefer to use fluent page object pattern in the Atata tests. If you don't like such approach, use without fluent pattern.

You can use random or predefined values in the test, as you like.

The control verification starts with Should property. There is a set of extension methods for different controls like: Equal, Exist, StartWith, BeGreater, BeEnabled, HaveChecked, etc.

That's all. Build project, run test and verify how it works. Check the docs to find out more about Atata.

Logging

Atata can generate log to different sources. As we configured AtataContext with UseAllNUnitFeatures, Atata will write logs to NUnit context. You can also use targets of NLog or log4net to write logs to files.

Test log

Here is a part of the test log:

2021-03-02 12:50:42.4649  INFO Starting test: Create
2021-03-02 12:50:42.4917 TRACE > Set up AtataContext
2021-03-02 12:50:42.4937 TRACE - Set: BaseUrl=https://demo.atata.io/
2021-03-02 12:50:42.4977 TRACE - Set: ElementFindTimeout=5s; ElementFindRetryInterval=0.5s
2021-03-02 12:50:42.4980 TRACE - Set: WaitingTimeout=5s; WaitingRetryInterval=0.5s
2021-03-02 12:50:42.4982 TRACE - Set: VerificationTimeout=5s; VerificationRetryInterval=0.5s
2021-03-02 12:50:42.4986 TRACE - Set: Culture=en-US
2021-03-02 12:50:42.5067 TRACE - Set: DriverService=ChromeDriverService on port 64593
2021-03-02 12:50:43.4007 TRACE - Set: Driver=ChromeDriver (alias=chrome)
2021-03-02 12:50:43.4029 TRACE < Set up AtataContext (0.910s)
2021-03-02 12:50:43.4917  INFO Go to "Sign In" page
2021-03-02 12:50:43.5439  INFO Go to URL "https://demo.atata.io/signin"
2021-03-02 12:50:44.9231 TRACE > Execute trigger VerifyTitleAttribute { Case=Title, Match=Equals, Format="{0} - Atata Sample App" } on Init against "Sign In" page
2021-03-02 12:50:44.9370  INFO - > Assert: title should equal "Sign In - Atata Sample App"
2021-03-02 12:50:45.9745  INFO - < Assert: title should equal "Sign In - Atata Sample App" (1.037s)
2021-03-02 12:50:45.9752 TRACE < Execute trigger VerifyTitleAttribute { Case=Title, Match=Equals, Format="{0} - Atata Sample App" } on Init against "Sign In" page (1.052s)
2021-03-02 12:50:45.9773 TRACE > Execute trigger VerifyH1Attribute { Index=-1, Case=Title, Match=Equals } on Init against "Sign In" page
2021-03-02 12:50:45.9880  INFO - > Assert: "Sign In" <h1> heading should exist
2021-03-02 12:50:46.0225 TRACE - - > Find visible element by XPath ".//h1[normalize-space(.) = 'Sign In']" in ChromeDriver
2021-03-02 12:50:46.0754 TRACE - - < Find visible element by XPath ".//h1[normalize-space(.) = 'Sign In']" in ChromeDriver (0.051s) >> Element { Id=a694ecd2-0874-4ba3-b61f-e4e3eb821f0a }
2021-03-02 12:50:46.0756  INFO - < Assert: "Sign In" <h1> heading should exist (0.087s)
2021-03-02 12:50:46.0758 TRACE < Execute trigger VerifyH1Attribute { Index=-1, Case=Title, Match=Equals } on Init against "Sign In" page (0.098s)
2021-03-02 12:50:46.0842  INFO > Set "admin@mail.com" to "Email" text input
2021-03-02 12:50:46.0889 TRACE - > Execute behavior ValueSetUsingClearAndSendKeysAttribute against "Email" text input
2021-03-02 12:50:46.0968 TRACE - - > Find visible element by XPath ".//label[normalize-space(.) = 'Email']" in ChromeDriver
2021-03-02 12:50:46.1321 TRACE - - < Find visible element by XPath ".//label[normalize-space(.) = 'Email']" in ChromeDriver (0.035s) >> Element { Id=bc2450f6-27bb-497b-80aa-ff428b95d440 }
2021-03-02 12:50:46.1501 TRACE - - > Find visible element by XPath ".//*[normalize-space(@id) = 'email']/descendant-or-self::input[@type='text' or not(@type)]" in ChromeDriver
2021-03-02 12:50:46.1803 TRACE - - < Find visible element by XPath ".//*[normalize-space(@id) = 'email']/descendant-or-self::input[@type='text' or not(@type)]" in ChromeDriver (0.030s) >> Element { Id=3baa8d49-2ac4-4f69-900e-e6be31daaa14 }
2021-03-02 12:50:46.1815 TRACE - - > Clear element { Id=3baa8d49-2ac4-4f69-900e-e6be31daaa14 }
2021-03-02 12:50:46.2280 TRACE - - < Clear element { Id=3baa8d49-2ac4-4f69-900e-e6be31daaa14 } (0.046s)
2021-03-02 12:50:46.2291 TRACE - - > Send keys "admin@mail.com" to element { Id=3baa8d49-2ac4-4f69-900e-e6be31daaa14 }
2021-03-02 12:50:46.3052 TRACE - - < Send keys "admin@mail.com" to element { Id=3baa8d49-2ac4-4f69-900e-e6be31daaa14 } (0.076s)
2021-03-02 12:50:46.3055 TRACE - < Execute behavior ValueSetUsingClearAndSendKeysAttribute against "Email" text input (0.216s)
2021-03-02 12:50:46.3057  INFO < Set "admin@mail.com" to "Email" text input (0.221s)
2021-03-02 12:50:46.3059  INFO > Set "abc123" to "Password" password input
2021-03-02 12:50:46.3061 TRACE - > Execute behavior ValueSetUsingClearAndSendKeysAttribute against "Password" password input
2021-03-02 12:50:46.3066 TRACE - - > Find visible element by XPath ".//label[normalize-space(.) = 'Password']" in ChromeDriver
2021-03-02 12:50:46.3378 TRACE - - < Find visible element by XPath ".//label[normalize-space(.) = 'Password']" in ChromeDriver (0.031s) >> Element { Id=461e982a-c6c4-414f-ac9f-c7c7bd16baeb }
2021-03-02 12:50:46.3476 TRACE - - > Find visible element by XPath ".//*[normalize-space(@id) = 'password']/descendant-or-self::input[@type='password']" in ChromeDriver
2021-03-02 12:50:46.3756 TRACE - - < Find visible element by XPath ".//*[normalize-space(@id) = 'password']/descendant-or-self::input[@type='password']" in ChromeDriver (0.027s) >> Element { Id=a92d523a-a4c9-4ab6-9455-477cef964b0d }
2021-03-02 12:50:46.3759 TRACE - - > Clear element { Id=a92d523a-a4c9-4ab6-9455-477cef964b0d }
2021-03-02 12:50:46.4203 TRACE - - < Clear element { Id=a92d523a-a4c9-4ab6-9455-477cef964b0d } (0.044s)
2021-03-02 12:50:46.4205 TRACE - - > Send keys "abc123" to element { Id=a92d523a-a4c9-4ab6-9455-477cef964b0d }
2021-03-02 12:50:46.4810 TRACE - - < Send keys "abc123" to element { Id=a92d523a-a4c9-4ab6-9455-477cef964b0d } (0.060s)
2021-03-02 12:50:46.4813 TRACE - < Execute behavior ValueSetUsingClearAndSendKeysAttribute against "Password" password input (0.175s)
2021-03-02 12:50:46.4815  INFO < Set "abc123" to "Password" password input (0.175s)
2021-03-02 12:50:46.4837  INFO > Click "Sign In" button
2021-03-02 12:50:46.4862 TRACE - > Execute behavior ClickUsingClickMethodAttribute against "Sign In" button
2021-03-02 12:50:46.4892 TRACE - - > Find visible element by XPath ".//*[self::input[@type='button' or @type='submit' or @type='reset'] or self::button][normalize-space(.) = 'Sign In' or normalize-space(@value) = 'Sign In']" in ChromeDriver
2021-03-02 12:50:46.5177 TRACE - - < Find visible element by XPath ".//*[self::input[@type='button' or @type='submit' or @type='reset'] or self::button][normalize-space(.) = 'Sign In' or normalize-space(@value) = 'Sign In']" in ChromeDriver (0.028s) >> Element { Id=0994387f-fd82-49f6-ab43-8b90c3aee738 }
2021-03-02 12:50:46.5186 TRACE - - > Click element { Id=0994387f-fd82-49f6-ab43-8b90c3aee738 }
2021-03-02 12:50:46.6419 TRACE - - < Click element { Id=0994387f-fd82-49f6-ab43-8b90c3aee738 } (0.123s)
2021-03-02 12:50:46.6421 TRACE - < Execute behavior ClickUsingClickMethodAttribute against "Sign In" button (0.155s)
2021-03-02 12:50:46.6423  INFO < Click "Sign In" button (0.158s)
2021-03-02 12:50:46.6544  INFO Go to "Users" page
...

Download

Check out the sources of Atata Framework on Atata GitHub page.

Get the sources of the demo test project on GitHub: Atata Sample App Tests. The demo project contains:

  • 20+ different auto-tests
  • Validation verification functionality
  • Logging functionality using NLog
  • Screenshot capturing

Contact

You can ask a question on Stack Overflow using atata tag or choose another contact option. Any feedback, issues and feature requests are welcome.

Atata Tutorials

History

  • 1st December, 2016: Initial version posted
  • 2nd December, 2016: Sample sources added
  • 4th April, 2017: Updated article content; added links to other Atata articles; updated sample sources
  • 26th September, 2017: Updated sample sources to use Atata v0.14.0; updated article content
  • 7th November, 2017: Updated sample sources to use Atata v0.15.0; updated article content
  • 5th June, 2018: Updated sample sources to use Atata v0.17.0; updated article content
  • 25th October, 2018: Updated sample sources to use Atata v1.0.0; updated "Features" and "Usage" sections content
  • 15th May, 2019: Updated sample sources to use Atata v1.1.0; updated links to documentation that was moved to a new domain
  • 2nd March, 2021: Updated sample sources to use Atata v1.10.0; updated the article content

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)

Share

About the Author

Yevgeniy Shunevych
Software Developer (Senior)
Ukraine Ukraine
C#/.NET Developer with 10+ years of experience in enterprise, web, desktop and automated testing software.
GitHub: https://github.com/YevgeniyShunevych

Comments and Discussions

 
QuestionPopup for Basic Authentication Pin
mgbloomfield5-Mar-21 9:59
Membermgbloomfield5-Mar-21 9:59 
QuestionVideos Pin
Satish Kumar Thirumalagiri16-Apr-19 9:25
MemberSatish Kumar Thirumalagiri16-Apr-19 9:25 
QuestionLoad testing? Pin
Member 105605568-Jun-18 0:18
professionalMember 105605568-Jun-18 0:18 
AnswerRe: Load testing? Pin
Yevgeniy Shunevych8-Jun-18 11:01
MemberYevgeniy Shunevych8-Jun-18 11:01 
QuestionError on loading Atata project Pin
waleedbadri18-Dec-17 23:56
Memberwaleedbadri18-Dec-17 23:56 
AnswerRe: Error on loading Atata project Pin
Yevgeniy Shunevych22-Dec-17 5:22
MemberYevgeniy Shunevych22-Dec-17 5:22 
QuestionSingle Page Apps (SPAs)? Pin
J Snyman26-Sep-17 23:30
MemberJ Snyman26-Sep-17 23:30 
AnswerRe: Single Page Apps (SPAs)? Pin
Yevgeniy Shunevych27-Sep-17 1:45
MemberYevgeniy Shunevych27-Sep-17 1:45 
QuestionWhat about windows desktop app? Pin
Win32nipuh6-Apr-17 2:08
professionalWin32nipuh6-Apr-17 2:08 
AnswerRe: What about windows desktop app? Pin
Yevgeniy Shunevych6-Apr-17 3:22
MemberYevgeniy Shunevych6-Apr-17 3:22 
GeneralRe: What about windows desktop app? Pin
Win32nipuh6-Apr-17 3:49
professionalWin32nipuh6-Apr-17 3:49 
GeneralMy vote of 5 Pin
Fawad Raza5-Apr-17 3:21
MemberFawad Raza5-Apr-17 3:21 
GeneralRe: My vote of 5 Pin
Yevgeniy Shunevych5-Apr-17 11:18
MemberYevgeniy Shunevych5-Apr-17 11:18 
Question2 вопрос Pin
garinov@mail.ru9-Feb-17 7:14
Membergarinov@mail.ru9-Feb-17 7:14 
AnswerRe: 2 вопрос Pin
Yevgeniy Shunevych12-Feb-17 6:12
MemberYevgeniy Shunevych12-Feb-17 6:12 
QuestionА как это запустить? Pin
garinov@mail.ru9-Feb-17 5:08
Membergarinov@mail.ru9-Feb-17 5:08 
AnswerRe: А как это запустить? Pin
Yevgeniy Shunevych12-Feb-17 6:09
MemberYevgeniy Shunevych12-Feb-17 6:09 

General General    News News    Suggestion Suggestion    Question Question    Bug Bug    Answer Answer    Joke Joke    Praise Praise    Rant Rant    Admin Admin   

Use Ctrl+Left/Right to switch messages, Ctrl+Up/Down to switch threads, Ctrl+Shift+Left/Right to switch pages.