Poglavje 5 P2.2 Testiranje in progresivne spletne aplikacije

P2.2

5.1 Osnovni funkcionalni testi aplikacije Demo s Selenium

Poleg varnostnih pregledov aplikacij so zelo pomembni tudi funkcionalni testi spletnih aplikacij. Mogoče na prvi pogled avtomatizacija testiranja uporabniškega vmesnika deluje kot zelo zahtevno opravilo, vendar imamo na voljo določena orodja, ki nam to olajšajo. Eno takšnih orodij je tudi ogrodje Selenium, ki avtomatizira delovanje spletnega brskalnika. Omogoča nam izvajanje vnaprej pripravljenih avtomatiziranih testov spletnih aplikacij, pri tem pa podpira množico različnih spletnih brskalnikov in programskih jezikov, v katerih lahko zapišemo in poganjamo teste.

S popolno avtomatizacijo testiranja aplikacij se pri tem tečaju ne bomo ukvarjali, če vas že na tem mestu zanimajo podrobnosti, si lahko preberete pregleden članek Setting up your own test automation environment, ki predstavi način avtomatiziranega testiranja spletnih aplikacij v produkcijskem okolju v okviru CI/CD podpornih aktivnosti.

Za poganjanje Selenium avtomatizacije se priporoča uporaba ločenega okolja (glej sliko 5.1), kjer je nameščen Selenium strežnik, in sicer kar v obliki Docker vsebnika. Za testiranje in izvajanje testov se zelo dobro izkaže selenium/standalone-chrome-debug, ki ga namestite z naslednjim ukazom (na sistemu moramo imeti nameščeno okolje Docker):

$ docker run -d -p 4445:4444 -p 5901:5900 --shm-size=2g selenium/standalone-chrome-debug

Če uporabljate ARM arhitekturo (npr. Apple M1 oz. M2), potem lahko uporabite naslednji Docker vsebnik:

$ docker run -d -p 4445:4444 -p 5901:5900 --shm-size 2g --name selenium-chrome-debug seleniarm/standalone-chromium:4.0.0-beta-1-20210215

Prednost omenjenega Docker vsebnika je v samostojnem Selenium strežniku, do katerega imamo tudi VNC dostop, kjer lahko sproti spremljamo izvajanje testa.

Arhitektura testnega sistema

Slika 5.1: Arhitektura testnega sistema

Opomba: Alternativa uporabe Docker vsebnika je namestitev Selenium strežnika in je na voljo v obliki datoteke JAR, ki jo preprosto poženete v samostojnem (angl. standalone) načinu z ukazom java -jar selenium-server-<verzija>.jar standalone --port 4445. Prav tako mora biti v isti mapi, kjer poganjate Selenium strežnik, na voljo tudi ChromeDriver.

Alternative: Poleg Seleniuma imamo na voljo tudi druge pogosto uporabljene alternative za testiranje programske opreme, in sicer Puppeteer, Cypress idr.

Selenium teste lahko zapišemo v poljubnem programskem jeziku (npr. C#, Java, JavaScript, R, Python ipd.). V nadaljevanju bo predstavljen primer funkcionalnega testa aplikacije Demo v programskem jeziku JavaScript z uporabo testov v ogrodjih Mocha in Chai.

Najprej bomo poskrbeli za namestitev vseh potrebnih Node.js knjižnic.

$ npm install selenium-webdriver chromedriver mocha mochawesome chai --save-dev

Nato kreiramo novo mapo test, kamor bomo shranili naš funkcionalni test Demo.test.js. Teste bomo razvili s pomočjo ogrodij Selenium, Mocha in Chai. Najprej v okviru spremenljivke seleniumServerUrl opredelimo naslov, kjer je na voljo Selenium strežnik, kamor se bo naš izvajalec testov povezal.

Nato s pomočjo ogrodja Mocha opredelimo funkcionalne teste, ki jih združimo v strukturo describe → context → it, kjer je it najmanjša enota funkcionalnosti, ki jo testiramo.

V našem primeru imamo opredeljene naslednje teste:

  • Location list
    • number of locations on the home page
  • About application
    • select about application
    • accuracy of data on the about aplication page
      • page title
      • page text
  • Location details
    • select Ljubljana - Viško pokopališče
    • accuray of data on the location details page
      • location title
      • location properties
      • location sidebar
  • Register new user
    • delete user from database
    • user login
    • select register
    • user data entry
    • check if user user is logged in
    • get JWT token
  • Add comment
    • delete user’s comments from the database
    • select Ljubljana - Viško pokopališče
    • check if Ljubljana - Viško pokopališče page is displayed
    • click the button to add a comment
    • check if the comment has been added
    • delete user’s comment
  • User logout
    • check if the user is logged in
    • request logout
    • check if the user is logged out

Pričakovane vrednosti nato preverjamo s pomočjo knjižnice Chai, kjer uporabimo rezervirano besedo expect v povezavi s primerjalnim operatorjem (npr. to.be.an, to.have.lengthOf, to.not.be.empty, to.have.string itd.).

test/Demo.test.js

/**
 * Functional tests
 */
(async function Demo() {
  /**
   * Libraries
   */
  const { execSync } = require("child_process");
  const { describe, it, after, before } = require("mocha");
  const { Builder, By } = require("selenium-webdriver");
  const chrome = require("selenium-webdriver/chrome");
  const expect = require("chai").expect;
  const https = require("https");
  /**
   * Parameters
   */
  let applicationUrl = "https://host.docker.internal:3000/";
  let seleniumServerUrl = "http://127.0.0.1:4445/wd/hub";
  let browser, jwtToken;
  const axios = require("axios").create({
    baseURL: applicationUrl + "api/",
    timeout: 5000,
  });
  /**
   * Error handling
   */
  process.on("unhandledRejection", (error) => console.log(error));
  /**
   * Confirm the exception for the self-signed certificate
   */
  let confirmHttpsException = async (browser) => {
    let button = await browser.findElement(
      By.xpath("//button[contains(text(), 'Advanced')]")
    );
    expect(button).to.not.be.empty;
    await button.click();
    let link = await browser.findElement(
      By.xpath("//a[contains(text(), 'Proceed to')]")
    );
    expect(link).to.not.be.empty;
    await link.click();
  };
  try {
    before(async () => {
      browser = new Builder()
        .forBrowser("chrome")
        .setChromeOptions(
          new chrome.Options()
            .addArguments("start-maximized")
            .addArguments("disable-infobars")
            .addArguments("allow-insecure-localhost")
            .addArguments("allow-running-insecure-content")
        )
        .usingServer(seleniumServerUrl)
        .build();
      await browser.manage().setTimeouts({ implicit: 30 * 1000 });
    });
    describe("Location list", () => {
      before(async () => {
        await browser.get(applicationUrl);
        await confirmHttpsException(browser);
      });

      it("number of locations on the home page", async () => {
        let locations = await browser.findElements(By.css(".card"));
        expect(locations).to.be.an("array").to.have.lengthOf(10);
      });
    });
    describe("About application", () => {
      before(async () => await browser.get(applicationUrl));
      it("select about application", async () => {
        let link = await browser.findElement(
          By.xpath("//a[contains(text(), 'Information')]")
        );
        expect(link).to.not.be.empty;
        await link.click();
        link = await browser.findElement(
          By.xpath("//a[contains(text(), 'About application')]")
        );
        expect(link).to.not.be.empty;
        await link.click();
      });
      context("accuracy of data on the about application page", () => {
        it("page title", async () => {
          let title = await browser.findElement(By.css("h1"));
          expect(title).to.not.be.empty;
          expect(await title.getText()).to.be.equal("About");
        });
        it("page text", async () => {
          let text = await browser.findElement(
            By.xpath("//p[contains(text(), 'boredom is still a part of life')]")
          );
          expect(text).to.not.be.empty;
          expect(await text.getText()).to.have.string(
            "Even though we have more activities and information serving our days than ever before – work, family, friends, travel, the internet, books, movies – boredom is still a part of life."
          );
        });
      });
    });
    describe("Location details", () => {
      before(() => browser.get(applicationUrl));
      it("select Ljubljana - Viško pokopališče", async () => {
        let link = await browser.findElement(
          By.xpath("//a[contains(text(), 'Ljubljana - Viško pokopališče')]")
        );
        expect(link).to.not.be.empty;
        await link.click();
      });
      context("accuray of data on the location details page", () => {
        it("location title", async () => {
          let title = await browser.findElement(By.css("h1"));
          expect(title).to.not.be.empty;
          expect(await title.getText()).to.be.equal(
            "Ljubljana - Viško pokopališče"
          );
        });
        it("location properties", async () => {
          let secondKeyword = await browser.findElement(
            By.xpath("//h6[text()='Keywords']/following-sibling::p/span[2]")
          );
          expect(secondKeyword).to.not.be.empty;
          expect(await secondKeyword.getText()).to.be.equal("portal");
        });
        it("location sidebar", async () => {
          let sidebar = await browser.findElement(
            By.xpath("//app-sidebar/small")
          );
          expect(sidebar).to.not.be.empty;
          expect(await sidebar.getText()).to.have.string(
            "Ljubljana - Viško pokopališče is on our Demo app"
          );
        });
      });
    });
    describe("Register new user", () => {
      before(async () => await browser.get(applicationUrl));
      it("delete user from database", async () => {
        let dockerDeleteUser =
          'docker exec -i sp-mongo-db bash -c "mongosh Demo --eval \'db.Users.deleteOne({ email: \\"janez@kranjski.net\\" })\'"';
        let result = execSync(dockerDeleteUser).toString();
        expect(result).to.match(/acknowledged: true/);
      });
      it("user login", async () => {
        let link = await browser.findElement(
          By.xpath("//a[contains(text(), 'Guest')]")
        );
        expect(link).to.not.be.empty;
        await link.click();
        link = await browser.findElement(
          By.xpath("//a[contains(text(), 'Login')]")
        );
        expect(link).to.not.be.empty;
        await link.click();
      });
      it("select register", async () => {
        let link = await browser.findElement(
          By.xpath("//a[contains(text(), 'register')]")
        );
        expect(link).to.not.be.empty;
        await link.click();
      });
      it("user data entry", async () => {
        let name = await browser.findElement(By.css("input[name='name']"));
        expect(name).to.not.be.empty;
        name.sendKeys("Janez Kranjski");
        let email = await browser.findElement(By.css("input[name='email']"));
        expect(email).to.not.be.empty;
        email.sendKeys("janez@kranjski.net");
        let password = await browser.findElement(
          By.css("input[name='password']")
        );
        expect(password).to.not.be.empty;
        password.sendKeys("test");
        let button = await browser.findElement(
          By.xpath("//button[contains(text(), 'Register')]")
        );
        await button.click();
      });
      it("check if user is logged in", async () => {
        let user = await browser.findElement(
          By.xpath("//a[contains(text(), 'Janez Kranjski')]")
        );
        expect(user).to.not.be.empty;
      });
      it("get JWT token", async () => {
        jwtToken = await browser.executeScript(() =>
          localStorage.getItem("demo-token")
        );
        expect(jwtToken).to.not.be.empty;
      });
    });
    describe("Add comment", async () => {
      before(async () => await browser.get(applicationUrl));
      it("select Ljubljana - Viško pokopališče", async () => {
        let link = await browser.findElement(
          By.xpath("//a[contains(text(), 'Ljubljana - Viško pokopališče')]")
        );
        expect(link).to.not.be.empty;
        await link.click();
      });
      it("check if Ljubljana - Viško pokopališče page is displayed", async () => {
        let title = await browser.findElement(
          By.xpath("//h1[contains(text(), 'Ljubljana - Viško pokopališče')]")
        );
        expect(title).to.not.be.empty;
        expect(await title.getText()).to.be.equal(
          "Ljubljana - Viško pokopališče"
        );
      });
      it("click the button to add a comment", async () => {
        await browser.executeScript(
          "window.scrollBy(0,document.body.scrollHeight)"
        );
        await browser.executeScript("document.querySelector('a.btn').click()");
        let modal = await browser.findElement(
          By.xpath("//h4[contains(@class,'modal-title')]")
        );
        expect(modal).to.not.be.empty;
        let rating = await browser.findElement(By.xpath("//span[@title=3]"));
        await rating.click();
        await browser
          .findElement(By.css("textarea[id='comment']"))
          .sendKeys("Najbolj so mi všeč igrala.");
        button = await browser.findElement(
          By.xpath("//button[contains(., 'Save')]")
        );
        await button.click();
      });
      it("check if the comment has been added", async () => {
        execSync('sleep 1');
        let lastComment = await browser.findElement(
          By.xpath(
            "//div[contains(@class, 'card-header') and contains(., 'comments')]/../div[@class='card-body']/div[1]"
          )
        );
        expect(lastComment).to.not.be.empty;
        let author = await lastComment.findElement(
          By.xpath("./div[1]/div[1]")
        );
        expect(author).to.not.be.empty;
        expect(await author.getText()).to.be.equal("Janez Kranjski");
        let comment = await lastComment.findElement(By.xpath("./div[2]"));
        expect(comment).to.not.be.empty;
        expect(await comment.getText()).to.be.equal(
          "Najbolj so mi všeč igrala."
        );
      });
      it("delete user's comments", async function () {
        let link = await browser.getCurrentUrl();
        let locationId = link.split("locations/")[1];
        expect(locationId).to.not.be.empty;
        let location = await axios({
          method: "get",
          url: "locations/" + locationId,
          httpsAgent: new https.Agent({ rejectUnauthorized: false }),
        });
        let comments = (await location.data.comments)
          .filter((x) => {
            return x.author == "Janez Kranjski";
          })
          .map((x) => x._id);
        for (const commentId of comments) {
          let response = await axios({
            method: "delete",
            url: "locations/" + locationId + "/comments/" + commentId,
            headers: { Authorization: "Bearer " + jwtToken },
            httpsAgent: new https.Agent({ rejectUnauthorized: false }),
          });
          expect(response.status).to.be.equal(204);
        }
        await browser.get(applicationUrl);
      });
    });
    describe("User logout", async () => {
      before(() => browser.get(applicationUrl));
      it("check if the user is logged in", async () => {
        let user = await browser.findElement(
          By.xpath("//a[contains(text(), 'Janez Kranjski')]")
        );
        expect(user).to.not.be.empty;
      });
      it("request logout", async () => {
        let user = await browser.findElement(
          By.xpath("//a[contains(text(), 'Janez Kranjski')]")
        );
        expect(user).to.not.be.empty;
        await user.click();
        let logout = await browser.findElement(
          By.xpath("//a[contains(text(), 'Logout')]")
        );
        expect(logout).to.not.be.empty;
        await logout.click();
      });
      it("check if the user is logged out", async () => {
        let login = await browser.findElement(
          By.xpath("//a[contains(text(), 'Login')]")
        );
        expect(login).to.not.be.empty;
      });
    });
    after(async () => {
      browser.quit();
    });
  } catch (error) {
    console.log("An error occurred during the test!");
    console.log(error);
  }
})();

Za potrebe avtomatizacije v datoteko package.json dodamo skripto test, kjer zahtevamo zagon mocha testov.

package.json

{
  ...
  "scripts": {
    ...,
    "test": "mocha test --bail --exit --reporter mochawesome --reporter-options autoOpen=true,reportDir=test/report,reportFilename=report,json=false"
  },
  ...
}

Ker Selenium odjemalec izvaja testne primere iz izoliranega Docker okolja, moramo poskrbeti še za spremembo naslova localhost v host.docker.internal.

angular/src/environments/environment.ts

export const environment = {
  apiUrl: "https://localhost:3000/api",
  apiUrl: "https://host.docker.internal:3000/api",
  production: false,
};

Spremembo moramo tudi uveljaviti, zato ponovno zahtevamo prevajanje Angular aplikacije.

$ cd angular
$ ng build --output-path build --configuration development
...
$ cd ..

Sedaj celotni test samo še poženemo z ukazom npm test in po želji s pomočjo orodja VNC pregledamo dejansko izvajanje testa.

$ npm test

> demo@0.1.0 test
> mocha test --bail --exit --reporter mochawesome --reporter-options autoOpen=true,reportDir=test/report,reportFilename=report,json=false

  Location list
    ✔ number of locations on the home page (819ms)

  About application
    ✔ select about application (132ms)
    accuracy of data on the about application page
      ✔ page title
      ✔ page text

  Location details
    ✔ select Ljubljana - Viško pokopališče (55ms)
    accuray of data on the location details page
      ✔ location title
      ✔ location properties
      ✔ location sidebar

  Register new user
    ✔ delete user from database (1083ms)
    ✔ user login (88ms)
    ✔ select register
    ✔ user data entry (152ms)
    ✔ check if user is logged in (63ms)
    ✔ get JWT token

  Add comment
    ✔ select Ljubljana - Viško pokopališče (58ms)
    ✔ check if Ljubljana - Viško pokopališče page is displayed
    ✔ click the button to add a comment (175ms)
    ✔ check if the comment has been added (1083ms)
    ✔ delete user's comments (478ms)

  User logout
    ✔ check if the user is logged in
    ✔ request logout (69ms)
    ✔ check if the user is logged out

  22 passing (7s)

[mochawesome] Report HTML saved to test/report/report.html

Rezultat izvajanja se shrani v datoteko report.html, ki se nahaja v mapi test/report in je prikazan na sliki 5.2.

Del poročila izvedbe testa z ogrodjem Mocha

Slika 5.2: Del poročila izvedbe testa z ogrodjem Mocha

5.2 Progresivne spletne aplikacije

5.2.1 Uvod

Progresivna spletna aplikacija (angl. Progressive Web App (PWA)) je posebna vrsta spletne aplikacije, ki je zasnovana tako, da je:

  • sposobna (angl. capable),
    • Že trenutne aplikacije na svetovnem spletu so zelo sposobne, saj lahko npr. zgradite napredno aplikacijo za video klepet s pomočjo obvestil WebRTC, uporabo geolokacije in potisnimi sporočili. Takšno aplikacijo je mogoče namestiti in jo dodatno podpreti s tehnologijami WebGL in WebVR. Z vpeljavo Web Assembly lahko razvijalci prenesejo številno obstoječo programsko opremo na splet.
  • zanesljiva (angl. reliable) in
    • Hitrost je ključnega pomena pri uporabniški izkušnji. Ko se čas nalaganja strani poveča iz \(1\ s\) na \(10\ s\), se verjetnost izgube stranke poveča za \(123\ \%\).
    • Zanesljive aplikacije morajo biti uporabne ne glede na status povezave.
  • jo je mogoče namestiti (angl. installable),
    • Nameščene progresivne aplikacije se izvajajo v samostojnem oknu in ne zgolj v zavihku brskalnika. Takšne aplikacije lahko tudi sprejemajo vsebino iz drugih aplikacij ali postanejo privzete za odpiranje določenih datotek.

V osnovi so progresivne aplikacije zgolj spletne aplikacije. Z vpeljavo postopnih izboljšav pa jim v sodobnih spletnih brskalnikih omogočimo nove sposobnosti. Kot prikazuje slika 5.3 gre za nadgradnjo spletne aplikacije, ki po sposobnostih dosega domorodno aplikacijo (npr. namizno aplikacijo za določen operacijski sistem.)

Primerjava PWA z domorodnimi in spletnimi aplikacijami

Slika 5.3: Primerjava PWA z domorodnimi in spletnimi aplikacijami

Zanimivost: Podjetja, ki so uvedla progresivne spletne aplikacije, v večini izboljšajo svojo uspešnost. Pri Twitterju se je za \(65\ \%\) povečalo število strani na sejo in uporabniki so kreirali \(75\ \%\) več sporočil, medtem ko so razvijalci uspeli zmanjšati velikost aplikacije za \(97\ \%\).

S progresivnimi spletnimi aplikacijami so povezane številne funkcionalnosti, ki jih lahko razdelimo v naslednji kategoriji:

  • Osnovne lastnosti progresivnih aplikacij:

    • Hiter zagon in delovanje sta ključna pri vsaki spletni uporabniški izkušnji, saj so spletna mesta z visoko zmogljivostjo bolj privlačna in bolje zadržijo uporabnike, kot manj zmogljiva.
    • Delovanje v vseh brskalnikih, saj imajo uporabniki izbran poljuben brskalnik za dostop do spleta, še preden namestijo vašo aplikacijo.
    • Odzivnost na poljubno velikost zaslona, saj lahko uporabniki PWA aplikacijo uporabljajo na poljubni velikosti zaslona, vsa vsebina pa je na voljo v poljubni velikosti prikaza.
    • Na voljo je stran po meri brez povezave, kjer imajo uporabniki boljšo izkušnjo, če ob izgubi povezave ostanejo znotraj vaše PWA, kot pa da so preusmerjeni na privzeto stran brez povezave na ravni brskalnika.
    • Možna je namestitev aplikacije, saj uporabniki, ki lahko aplikacijo namestijo ali dodajo v svojo napravo, samo aplikacijo tudi bolj uporabljajo.
  • Napredne lastnosti progresivnih aplikacij:

    • Zagotavlja izkušnjo brez povezave pri tistih funkcionalnostih, kjer povezljivost ni potrebna in aplikacija deluje enako kot s povezavo v splet.
    • Popolna dostopnost, kjer so vse uporabniške interakcije skladne z WCAG 2.0.
    • Enostavno jo je odkriti z iskanjem, kjer se osredotočimo predvsem na dostopnost s strani spletnih pajkov večjih spletnih iskalnikov. Ključno je, da ima vsak URL naslov enoličen naslov in meta oznako.
    • Deluje s poljubno vrsto vnosa, kjer je na voljo uporabniška izkušnja z miško, tipkovnico, pisalom ali dotikom.
    • Zagotavlja kontekst pri varnostnih zahtevah, kjer pri zahtevi dovoljenja za uporabo zmogljivih funkcionalnosti uporabniku opredelite kontekst in jih zahtevajte le takrat, ko je to potrebno.

Progresivne spletne aplikacije izkoriščajo zmogljivosti sodobnih spletnih tehnologij in ponujajo nabor najboljših praks in sodobnih spletnih API-jev. Kot prikazuje slika 5.4 lahko z uporabo Service worker-ja obstoječo stran z uporabo predpomnjenja virov in napovedovanja nalaganja pohitrimo in naredimo bolj zanesljivo. Če poleg tega omogočimo še namestitev na napravo, lahko uporabniki enostavno dostopajo do aplikacije kar neposredno iz domačega zaslona. Za obveščanje uporabnikov in ponovno vključitev v aplikacijo pa lahko izkoristimo tehnologijo spletnih potisnih obvestil.

Povzetek PWA funkcionalnosti

Slika 5.4: Povzetek PWA funkcionalnosti

V nadaljevanju si bomo pogledali, kako obstoječo aplikacijo postopoma pretvorimo v progresivno in si pri tem pomagamo z obstoječimi orodji za analizo spletnih aplikacij.

5.2.2 Pregled obstoječe aplikacije s pomočjo Lighthouse

Lighthouse je odprtokodno orodje za revizijo spletnih aplikacij, ki so ga razvili pri Googlu in se lahko uporablja za pregled spletnih mest in aplikacij z vidika učinkovitosti, dostopnosti, SEO, najboljših praks in podpore PWA.

Zaradi lažjega testiranja bomo zopet sprememnili naslov iz host.docker.internal v localhost.

angular/src/environments/environment.ts

export const environment = {
  apiUrl: "https://localhost:3000/api",
  apiUrl: "https://host.docker.internal:3000/api",
  production: false,
};

Spremembo moramo tudi uveljaviti, zato ponovno zahtevamo prevajanje Angular aplikacije.

$ cd angular
$ ng build --output-path build --configuration development
...
$ cd ..
$ npm start

Do orodja Lighthouse lahko najlažje dostopate v spletnem brskalniku Google Chrome, in sicer odprete JavaScript konzolo, kjer je funkcionalnost na voljo v zavihku Lighthouse.

Pri izvedbi revizije spletne aplikacije lahko izbiramo med različnimi kategorijami (zmogljivost (angl. performance), PWA, najboljše prakse (angl. best practices), dostopnost (angl. accessibility) in SEO), napravami (mobilna naprava (anlg. mobile) ali namizni računalnik (angl. desktop)) in načinom delovanja (navigacija (angl. navigation), časovno obdobje (angl. timespan) oz. posnetek (angl. snapshot)), kot je prikazano na sliki 5.5.

Začetne nastavitve v Lighthouse

Slika 5.5: Začetne nastavitve v Lighthouse

Za začetek bomo izvedli revizijo nad vsemi kategorijami za namizni računalnik. Ko se analiza izvede, najprej dobimo povzetek rezultatov, ki je prikazan na sliki 5.6.

Povzetek rezultatov analize

Slika 5.6: Povzetek rezultatov analize

Opazimo, da ima naša aplikacija kar nekaj težav pri zmogljivosti (angl. performance) ter SEO, medtem ko PWA pri našem dosedanjem razvoju sploh nismo naslovili.

Za vsako izmed kategorij si lahko pogledamo podrobnosti, kjer so najbolj koristna priporočila za izboljšanje delovanja naše aplikacije.

Pri analizi zmogljivosti (angl. performance) na sliki 5.7 med metrikami vidimo podrobnosti o posamezni izboljšavi, vključno s potencialnim doprinosom v času nalaganja. Za vsako priporočilo imamo na voljo tudi podrobnosti, kako jo implementirati. Največ težav je povezanih z neoptimizirano uporabo knjižnic (npr. Angular ipd.).

Rezultati analize zmogljivosti

Slika 5.7: Rezultati analize zmogljivosti

Naslednja kategorija je dostopnost (angl. accessibility), kjer so podrobnosti prikazane na sliki 5.8. Opazimo lahko manjše pomanjkljivosti v zvezi s kontrastom in uporabo velikosti naslovov.

Rezultati analize dostopnosti

Slika 5.8: Rezultati analize dostopnosti

V okviru kategorije uporabe dobrih praks (angl. best practices) so podrobnosti na voljo na sliki 5.9, kjer smo dosegli vse možne točke.

Rezultati analize uporabe dobrih praks

Slika 5.9: Rezultati analize uporabe dobrih praks

Z vidikom optimizacije spletne strani SEO za prikaz med rezultati spletnega iskanja se pri našem razvoju sploh nismo ukvarjali, zato smo tudi dosegli nižjo oceno. Imamo pa sedaj na sliki 5.10 zbrana vsa priporočila, ki bi jih bilo smiselno upoštevati.

Rezultati analize SEO

Slika 5.10: Rezultati analize SEO

Na tem mestu je za nas najbolj koristno poročilo PWA, ki je na voljo na sliki 5.11. Opazimo lahko, da naša aplikacija ni skladna z zahtevami progresivnih aplikacij, zato jih bomo v nadaljevanju postopoma dodali.

Rezultati analize PWA

Slika 5.11: Rezultati analize PWA

5.2.3 Vključitev podpore za PWA

V okviru ogrodja Angular imamo neposredno podporo za izdelavo progresivnih aplikacij. Takšna aplikacija ponuja izboljšano uporabniško izkušnjo, ki je zelo podobna domorodni aplikaciji.

$ cd angular
$ ng add @angular/pwa@16.2.8
ℹ Using package manager: npm
✔ Package information loaded.

The package @angular/pwa@16.2.8 will be installed and executed.
Would you like to proceed? Yes

✔ Packages successfully installed.
CREATE ngsw-config.json (631 bytes)
CREATE src/manifest.webmanifest (1332 bytes)
CREATE src/assets/icons/icon-128x128.png (1124 bytes)
CREATE src/assets/icons/icon-144x144.png (1291 bytes)
CREATE src/assets/icons/icon-152x152.png (1306 bytes)
CREATE src/assets/icons/icon-192x192.png (1654 bytes)
CREATE src/assets/icons/icon-384x384.png (3557 bytes)
CREATE src/assets/icons/icon-512x512.png (5008 bytes)
CREATE src/assets/icons/icon-72x72.png (711 bytes)
CREATE src/assets/icons/icon-96x96.png (857 bytes)
UPDATE angular.json (3283 bytes)
UPDATE package.json (880 bytes)
UPDATE src/app/app.module.ts (2728 bytes)
UPDATE src/index.html (783 bytes)
✔ Packages installed successfully.

V nadaljevanju bomo postopoma nadgradili ogrodje PWA aplikacije, ki smo ga dodali obstoječi SPA aplikaciji. Ključna funkcionalnost, ki jo želimo nasloviti, je delovanje aplikacije v stanju brez povezave, kjer bomo poskrbeli za čim boljšo uporabniško izkušnjo.

5.2.3.1 Testiranje PWA aplikacij

Pri testiranju funkcionalnosti progresivnih aplikacij je treba biti previden, saj jih Angular privzeto omogoči zgolj v produkcijski verziji aplikacije, kar je tudi smiselno. Če bi te funkcionalnosti bile na voljo tudi v razvojni različici (z zagonom ng serve), bi v procesu testiranja naša aplikacija vračala shranjene medpomnjene vrednosti, kar bi otežilo razvoj.

Zaradi tega je pri testiranju PWA aplikacij potreben dodaten korak, in sicer generiranje produkcijske verzije aplikacije ter strežba te verzije v samostojnem spletnem strežniku ali kar s pomočjo že obstoječe Express aplikacije, kjer imamo to podporo že na voljo. Če bi želeli zgolj preveriti delovanje Angular PWA aplikacije pa je priporočilo, da uporabimo kar npm modul http-server, kjer gre za enostaven spletni strežnik, s katerim lahko postrežemo našo produkcijsko Angular PWA aplikacijo.

Namig: Spletni strežnik namestimo z ukazom npm install -g http-server.

Tudi tokrat si bomo ponavljajoča opravila avtomatizirali v obliki skript, in sicer v okviru package.json datoteke Angular aplikacije.

angular/package.json

{
  ...
  "scripts": {
    ...
    "build": "ng build --output-path build",
    ...
  },
  ...
}

Dopolnili smo skripto build, kjer zahtevamo izgradnjo razvojne verzije Angular aplikacije v mapi build, ki jo nato postreže Express strežnik.

Ker smo pri predhodnjem razvoju zanemarili preverjanje produkcijske različice, moramo sedaj dopolniti določene pomanjkljivosti.

Najprej bomo tudi v produkcijskem okolju spremenili lokacijo REST API strežnika na lokalno okolje.

angular/src/environments/environment.prod.ts

export const environment = {
  apiUrl: "https://web-dev.fly.dev/api",
  apiUrl: "http://127.0.0.1:3000/api",
  production: true,
};

Prav tako bomo zaradi lokalnega testiranja izključili varno povezavo HTTPS.

.env

# HTTPS
HTTPS=truefalse

V nadaljevanju se bomo postopoma lotili dodajanja podpore brskanja brez povezave.

5.2.4 Brskanje brez povezave

5.2.4.1 Indikator povezave

V obstoječi aplikaciji bomo najprej vpeljali indikator, da je odjemalec, na katerem se aplikacija izvaja, brez povezave v omrežje in ga bomo prikazali na uporabniškem vmesniku v orodni vrstici, kot to prikazuje slika 5.12.

Indikator povezave v orodni vrstici aplikacije

Slika 5.12: Indikator povezave v orodni vrstici aplikacije

Funkcionalnost bomo implementirali s pomočjo nove storitve connection in indikatorja isConnected, ki se ob spremembi stanja povezave s pomočjo dogodkov tudi ustrezno posodobi.

$ ng generate service shared/services/connection
CREATE src/app/shared/services/connection.service.ts (139 bytes)

angular/src/app/shared/services/connection.service.ts

import { Injectable } from "@angular/core";
import { fromEvent, merge, Observable, Observer } from "rxjs";
import { map } from "rxjs/operators";

@Injectable({
  providedIn: "root",
})
export class ConnectionService {
  public isConnected: boolean = false;
  constructor() {
    merge(
      fromEvent(window, "offline").pipe(map(() => false)),
      fromEvent(window, "online").pipe(map(() => true)),
      new Observable((sub: Observer<boolean>) => {
        sub.next(navigator.onLine);
        sub.complete();
      })
    ).subscribe((connected: boolean) => (this.isConnected = connected));
  }
}

Omenjeno funkcionalnost bomo najprej vključili v osrednjo komponento framework, kjer bo na voljo kot vizualni indikator, ki je prikazan na sliki 5.12.

angular/src/app/shared/components/framework/framework.component.ts

...
import { ConnectionService } from "../../services/connection.service";
@Component({
  selector: "app-framework",
  templateUrl: "framework.component.html",
  styles: [],
})
export class FrameworkComponent implements OnInit {
  constructor(
    private authenticationService: AuthenticationService,
    private historyService: HistoryService,
    private connectionService: ConnectionService
  ) {}
  public isConnected(): boolean {
    return this.connectionService.isConnected;
  }
  ...
}

Pogled dopolnimo do te mere, da vključimo ikono, ki se prikaže takrat, ko povezava ni aktivna.

angular/src/app/shared/components/framework/framework.component.html

<nav class="navbar navbar-expand-md navbar-dark bg-primary">
  <div class="container-fluid">
    ...
    <div class="collapse navbar-collapse" id="navbarContent">
      ...
      <ul class="navbar-nav justify-content-end">
        <li class="nav-item p-2" *ngIf="!isConnected()">
          <i class="fa-solid fa-triangle-exclamation text-light"></i>
        </li>
        ...
      </ul>
    </div>
  </div>
</nav>

5.2.4.2 Onemogočene funkcionalnosti v načinu brez povezave

Če odjemalec nima povezave do REST API strežnika, je smiselno določene funkcionalnosti onemogočiti, in sicer: prijavo, registracijo, prikaz Google Maps zemljevida in dodajanje komentarjev.

Podobno kot pri komponenti framework, bomo sedaj tudi pri komponentah login in register dodali referenco na storitev connection ter poskrbeli za onemogočanje vnosnih polj in gumba spletnih obrazcev.

angular/src/app/shared/components/login/login.component.ts

...
import { ConnectionService } from "../../services/connection.service";
@Component({
  selector: "app-login",
  template: `<app-header [content]="header"></app-header>
    <div class="row">
      <div class="col-12 col-md-8">
        ...
        <form (ngSubmit)="onLoginSubmit()" autocomplete="off">
          ...
          <div class="form-group mt-3">
            <button
              [disabled]="!isConnected()"
              type="submit"
              class="btn btn-sm btn-primary me-2"
            >
              <i class="fa-regular fa-circle-check pe-2"></i>Login
            </button>
          </div>
        </form>
      </div>
      ...
    </div>`,
  styles: [],
})
export class LoginComponent implements OnInit {
  constructor(
    private router: Router,
    private authenticationService: AuthenticationService,
    private historyService: HistoryService,
    private connectionService: ConnectionService
  ) {}
  public isConnected(): boolean {
    return this.connectionService.isConnected;
  }
  ...
}

angular/src/app/shared/components/register/register.component.ts

...
import { ConnectionService } from "../../services/connection.service";

@Component({
  selector: "app-register",
  template: `<app-header [content]="header"></app-header>
    <div class="row">
      <div class="col-12 col-md-8">
        ...
        <form (ngSubmit)="onRegisterSubmit()" autocomplete="off">
          ...
          <div class="form-group mt-3">
            <button
              [disabled]="!isConnected()"
              type="submit"
              class="btn btn-sm btn-primary me-2"
            >
              <i class="fa-regular fa-circle-check pe-2"></i>Register
            </button>
          </div>
        </form>
      </div>
      ...
    </div>`,
  styles: [],
})
export class RegisterComponent implements OnInit {
  constructor(
    private router: Router,
    private authenticationService: AuthenticationService,
    private historyService: HistoryService,
    private connectionService: ConnectionService
  ) {}
  public isConnected(): boolean {
    return this.connectionService.isConnected;
  }
  ...
}

V okviru pregleda podrobnosti izbrane zanimivosti je na voljo tudi dodajanje komentarjev, ki v primeru načina brez povezave ne deluje. Zato bomo obrazec za vnos komentarjev skrili, gumb za dodajanje novega komentarja in prijavo za komentiranje pa onemogočili.

Ravno tako v načinu brez povezave ne bomo prikazovali lokacije na Google Maps zemljevidu in bomo vsebino na levi strani (opis in oznake) razširili čez področje, kjer bi se nahajal zemljevid.

angular/src/app/shared/components/location-details/location-details.component.ts

...
import { ConnectionService } from "../../services/connection.service";
...
export class LocationDetailsComponent implements OnInit {
  modalRef?: BsModalRef;
  constructor(
    private modalService: BsModalService,
    private demoDataService: DemoDataService,
    private authenticationService: AuthenticationService,
    private connectionService: ConnectionService
  ) {}
  public isConnected(): boolean {
    if (!this.connectionService.isConnected) this.closeModal();
    return this.connectionService.isConnected;
  }
  ...
}

angular/src/app/shared/components/location-details/location-details.component.html

<div class="row" *ngIf="location">
  <div
    class="col-12 col-md-6"
    [ngClass]="{ 'col-12': true, 'col-md-6': isConnected() }"
  >
    ...
  </div>
  <div class="col-12 col-md-6" *ngIf="isConnected()">
    ...
  </div>
  <div class="col-12">
    <div class="card mt-4">
      <div class="card-header bg-light">
        <div class="d-flex justify-content-between align-items-center">
          ...
          <div [ngSwitch]="isLoggedIn()">
            <a
              *ngSwitchCase="true"
              class="btn btn-primary btn-sm"
              [ngClass]="{ 'disabled': !isConnected() }"
              (click)="openModal(commentForm)"
              ><i class="fa-regular fa-comment-dots pe-2"></i
              ><span>Add comment</span></a
            ><a
              *ngSwitchDefault
              routerLink="/login"
              class="btn btn-primary btn-sm"
              [ngClass]="{ 'disabled': !isConnected() }"
              ><i class="fa-solid fa-right-to-bracket pe-2"></i>Login to post
              comment</a
            >
          </div>
        </div>
      </div>
      ...
    </div>
  </div>
</div>

5.2.4.3 Medpomnjenje oddaljenih podatkov

Ko smo naši aplikaciji dodali PWA podporo, se je kreiral tudi Service worker, ki skrbi za medpomnjenje podatkov na strani odjemalca v okviru njegovega spletnega brskalnika.

Osrednja konfiguracijska datoteka, kjer lahko opredelimo delovanje mehanizma medpomnjenja je ngsw-config.json. Vse nastavitve konfiguracije Service worker lahko najdete med uradno Angular dokumentacijo.

Privzete vrednosti, ki so nastavljene ob dodajanju podpore PWA obstoječi aplikaciji, bomo dopolnili tako, da bomo še dodatno zahtevali medpomnjenje zahtev na REST API strežnik (lokalno namestitev in produkcijsko okolje).

angular/ngsw-config.json

{
  "$schema": "./node_modules/@angular/service-worker/config/schema.json",
  "index": "/index.html",
  "assetGroups": [
    {
      "name": "app",
      "installMode": "prefetch",
      "resources": {
        "files": [
          "/favicon.ico",
          "/index.html",
          "/manifest.webmanifest",
          "/*.css",
          "/*.js"
        ]
      }
    },
    {
      "name": "assets",
      "installMode": "lazy",
      "updateMode": "prefetch",
      "resources": {
        "files": [
          "/assets/**",
          "/*.(svg|cur|jpg|jpeg|png|apng|webp|avif|gif|otf|ttf|woff|woff2)"
        ]
      }
    }
  ],
  "dataGroups": [
    {
      "name": "demo-api",
      "urls": [
        "http://127.0.0.1:3000/**",
        "https://127.0.0.1:3000/**",
        "http://host.docker.internal:3000/**",
        "https://host.docker.internal:3000/**",
        "https://web-dev.fly.dev/api/**"
      ],
      "cacheConfig": {
        "strategy": "freshness",
        "maxSize": 20,
        "maxAge": "1h",
        "timeout": "5s"
      }
    }
  ]
}

Pri opredelitvi medpomnjenja zahtev REST API strežnika zahtevamo freshness strategijo osveževanja (vedno poskušamo dobiti dejanske podatke in postrežemo medpomnjene šele takrat, ko povezava ni na voljo po časovnem poteku 5s (timeout)), in sicer hranimo 20 odzivov strežnika (maxSize), kjer so v medpomnilniku shranjeni najdlje 1h.

Če se še enkrat osredotočimo na rezultat testiranja naše aplikacije z vidika podpore PWA, predstavljene na sliki 5.11, moramo izpeljati še manjšo dopolnitev, da bodo testi orodja Lighthouse pozitivni. Poskrbeti je treba za ikone Apple naprav v datoteki index.html. Pri tem lahko preprosto uporabimo eno izmed ikon aplikacije, ki je bila samodejno generirana ob dodajanju podpore PWA naši aplikaciji.

angular/src/index.html

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title>Demo</title>
    <base href="/" />
    <meta name="viewport" content="width=device-width, initial-scale=1" />
    <link rel="icon" type="image/x-icon" href="favicon.ico" />
    <link rel="apple-touch-icon" href="/assets/icons/icon-192x192.png" />
    <link rel="stylesheet" href="/assets/styles/fa.all.min.css" />
    <link rel="stylesheet" href="/assets/styles/bootstrap.sandstone.min.css" />
    <link rel="stylesheet" href="/assets/styles/style.css" />
    <link rel="manifest" href="manifest.webmanifest" />
    <meta name="theme-color" content="#1976d2" />
  </head>
  <body>
    <app-framework></app-framework>
    <script src="/assets/javascript/bootstrap.bundle.min.js"></script>
    <noscript
      >Please enable JavaScript to continue using this application.</noscript
    >
  </body>
</html>

5.2.5 Končno testiranje

Ko izvedemo vse spremembe, lahko z ukazom npm run build generiramo produkcijsko verzijo. Ko ponovno izvedemo analizo skladnosti z zahtevami PWA v okviru orodja Lighthouse, ugotovimo, da smo našo aplikacijo uspešno pretvorili v progresivno aplikacijo, kar prikazuje slika 5.13.

Rezultati končne analize PWA

Slika 5.13: Rezultati končne analize PWA

Seveda so možne še številne izboljšave, vendar smo sedaj izboljšali uporabniško izkušnje obstoječe aplikacije do te mere, da je sama funkcionalnost zelo blizu domorodne aplikacije, saj za svoje delovanje ne potrebuje stalne spletne povezave.

Orodje Lighthouse smo poganjali preko brskalnika Chrome, lahko pa namestimo tudi Lighthouse Node CLI.

$ npm install -g lighthouse
$ lighthouse http://localhost:3000/ --view