Exercises

Work down the list. Each level assumes the one before it. Everything runs against the practice ledger — keep it open in another tab.

Signing in. The ledger asks for a login. Use demo@practice.invalid with the password practice — they are printed on the sign-in screen too. There is no real account behind it. The session ends when you close the tab, which is deliberate: it means you can practise the login as often as you like.
How to check your work. There is no marking button on purpose. Every task here can be verified by looking at the app: a number you calculated should match a number on the screen, or a change you made should be visible in the list. If the two disagree, your script is wrong — that is the exercise.

Level 1 — Read the page

Selectors

1.1 Count the rows

Open Sales › Invoices (#/sales/invoices). Clicking a top-level nav item opens its menu rather than navigating, so pick Invoices from the dropdown. In the console, count the rows on screen.

document.querySelectorAll('#invoice-table tbody tr').length

You should get 15. Now change the status filter to Draft by hand and run it again.

1.2 Pull out the invoice numbers

Produce an array of the invoice numbers on the current page. Then produce an array of objects with number, contact and total for each row.

Hint: look at the cells in DevTools. Each one carries a data-col attribute. Use it — do not count columns with :nth-child().

1.3 Numbers, not text

Add up the Total column for the page and compare it to the Total on this page figure in the table footer.

Trap: "25,292.00" is a string. If you feed it straight to arithmetic you get NaN, and if you only strip the comma you may still be off. Each total cell also carries a data-amount attribute with a clean machine-readable number. Find it and use it.

1.4 Dates, not text

Get the due date of every row as YYYY-MM-DD.

Trap: the screen says 31 Jul 2026. Parsing that back into a date is possible but pointless — the cell carries data-iso. The lesson is general: before writing a parser, look for the value the page already has.

Level 2 — Drive the controls

Events

2.1 Change the filter by script

Set the status dropdown to Overdue from code, and make the table actually reload.

const sel = document.getElementById('invoice-status-filter');
sel.value = 'Overdue';
// ...this alone does nothing. Why?

Trap: assigning .value changes what the box displays. It does not tell the app anything happened. You have to dispatch the event the app is listening for: sel.dispatchEvent(new Event('change', {bubbles: true})). Remember this one — it is the most common reason a web automation "silently does nothing".

2.2 Type into the search box

Search for Tanglin from code. Note that search listens for input, not change, and that it waits 300ms after you stop typing before it reloads.

2.3 A control that does not act on its own

Open Reporting › Aged Receivables Summary. Roll the As at date back 60 days from code and re-read the table.

Trap: nothing happens — and this time firing the event does not help either. This report only rebuilds when you press Update. Two different patterns live in the same app: the invoice filter reloads the moment it changes, this one waits to be told. Check which you are dealing with before assuming.

Set the date, click Update, wait for the new table, and confirm you got it by reading #aged-report's data-as-at attribute rather than trusting that enough time has passed.

2.4 Automate the sign-in

Click your name at the top right, choose Sign out, then get back in from code: fill both fields, click Sign in, and end up on the ledger.

Trap: the Sign in button starts disabled and only wakes up once both fields fire an input event. Assign .value to both and click, and nothing happens — the button is still disabled. Check document.getElementById('login-submit').disabled is false before you click.

Then try it with a deliberately wrong password and make your script notice. A real automation that silently carries on after a failed login is worse than one that crashes.

Worth knowing: signing in navigates the page, which destroys everything your script was holding. Anything you want to keep across that boundary has to survive in the URL, in storage, or in a bookmarklet you run again on the other side. This is the single biggest difference between automating one page and automating a workflow.

Level 3 — Wait properly

Async

3.1 Break your own script

Set Latency in the yellow bar to Slow. Re-run your Level 2 script. It will read the old table, or an empty one, because the new rows had not arrived yet.

3.2 Write waitFor

Write a helper that polls for a condition instead of guessing at a delay, and give up after a timeout rather than hanging forever.

function waitFor(test, timeout = 10000, interval = 100) {
  return new Promise((resolve, reject) => {
    const started = Date.now();
    (function poll() {
      const result = test();
      if (result) return resolve(result);
      if (Date.now() - started > timeout) return reject(new Error('timed out'));
      setTimeout(poll, interval);
    })();
  });
}

Now rewrite 2.1 as: change the filter, await until the loading block is gone and rows are present, then read them. It should work identically at every latency setting. That is the standard to hold yourself to from here on.

Why not just sleep(3000)? Because you are guessing. Too short and it breaks on a slow day; too long and a 200-row job takes an hour. Wait for the condition, not the clock.

Level 4 — Walk every page

Loops

4.1 Collect all 68 invoices

The list shows 15 at a time. Write a loop that starts at page 1, scrapes the rows, clicks to the next page, waits for it, and stops at the last page. Return one array of all 68.

Hint: the pager carries data-page-count and data-current-page. Use those to decide when to stop rather than clicking Next until something breaks.

4.3 The same scraper, a different table

Go to Sales › Quotes. That table carries the same data-col hooks as the invoice list, but the columns differ — it has a Title and an Expiry rather than a Due date.

Rewrite your scraper so the column list is an argument rather than hard-coded, then point it at both tables. If you find yourself copying and pasting the whole function and editing the middle, stop and do it properly — this is the difference between a script and a tool.

Then list every quote that has expired but is still marked Sent. There should be none: check that your code agrees, and be sure you would have spotted it if there were.

4.2 Cross-check your total

Filter to Overdue, collect every page, and sum the totals. Check your figure three ways — they must all agree:

If they disagree, you dropped a page or double-counted one. This is how you check an automation you cannot easily eyeball.

Level 5 — Fill in a form

Data in

5.1 Create one invoice by hand first

Genuinely do it by hand, clicking through New invoice. Watch what the app demands: a customer picked from the dropdown, a description, a quantity, a price. You cannot automate a process you have not done once.

5.2 Now do it by script

Create an invoice for Tanglin Dental Group, 2 × $1,500 of "Automation training workshop", and approve it.

Trap 1 — the customer box. Typing the name into the field is not selecting the customer. The app stores the customer's id in a hidden field, and only filling that in counts. You have to fire an input event, wait for the suggestion list to appear, and click the option you want. Check #invoice-contact-id has a value before you save.

Trap 2 — the silent line. Set a line's quantity and price with .value and no event, and watch the running total ignore it. Worse, the line will not be saved at all — the form only registers a field when it fires an event. Check the total on screen matches what you expect before you click Approve.

Trap 3 — the save takes time. The button spins, then the app navigates to the new invoice. Do not start the next one until that has happened.

5.3 Ten at once

Here is a batch. Create all ten, each on 30-day terms, saved as drafts.

const batch = `customer,description,qty,price
Tanglin Dental Group,Quarterly compliance review,1,1850
Meridian Logistics Pte Ltd,Implementation fee,1,4800
Anson Legal LLP,On-site training session,2,950
Katong Heritage Bakery,Support package - Tier 2,3,680
Marina Software Labs,Data migration services,1,2150
Bedok Electrical Works,Custom report development,2,1450
Novena Medical Supplies,Annual software licence,1,2400
Seletar Marine Services,Project consulting (per day),4,1200
Clementi Tuition Centre,Delivery and installation,1,420
Jurong Cold Chain Pte Ltd,Monthly retainer - advisory services,1,3500`;

const rows = LabUI.parseCsv(batch);   // first row is the header

Your script must return to the form between each one, and it must tell you which rows succeeded and which failed rather than stopping dead on the first problem. Verify by filtering the invoice list to Draft — you should see your ten on top of the nine that were already there.

Then run it at Slow latency. If it still passes, your waiting is correct.

Level 6 — Get data out

Data out

6.1 Look at a worked example

The Contacts page has an Export CSV button. Open assets/js/app.js, find export-contacts, and read the eight lines that do it.

6.2 Build the one that is missing

The invoice list deliberately has no export. Build it: walk every page, collect number, customer, issue date, due date, status and total, and download it as invoices.csv.

LabUI.downloadText('invoices.csv', LabUI.toCsv(rows), 'text/csv');

Open the result in Excel. If a customer name containing a comma has split across two columns, your quoting is wrong.

Level 7 — Click through a queue

Repetition

7.1 Reconcile the small stuff

Go to Accounting › Bank accounts, then Reconcile (#/accounting/bank-accounts/reconcile). Reconcile every statement line under SGD 1,000 — and only those — by clicking its OK button.

Trap: each row disappears once reconciled, so the list you collected up front goes stale as you work through it. Decide whether to capture the ids first or re-query each time, and be able to explain why. Also: the amounts include negatives. "Under 1,000" means what, exactly? Decide, then write it down in a comment.

The counter in the page heading tells you how many are left. Your script's idea of how many it reconciled should match the drop in that number.

Level 8 — When the page will not help you

Fragility

8.1 Scrape the bills

Your invoice scraper leans on data-col. Now scrape Bills to pay into the same shape. Inspect it first — that table has no helpful attributes at all, and its class names look machine-generated.

The real lesson: classes like c-7f3a1b come out of a build tool and change the next time the vendor deploys. Anchoring on them gives you an automation that breaks silently one Tuesday morning. Prefer, in order: a stable id or data-* hook; the table's position in the page structure; the column's header text. Write down which you chose and what would break it.

8.2 Find the column by its heading

Purchases › Purchase orders has the same problem and one extra wrinkle: its columns are in a different order from the bills table.

Write a helper that takes a table and a heading like "Delivery", finds which column index that heading sits in, and reads that cell from every row. Use it for both tables. A scraper built this way survives the vendor reordering columns — a :nth-child(4) one does not.

Level 9 — Make the reports agree

Cross-checks

9.1 Tie the balance sheet to the ledger

Every report under Reporting is calculated from the same invoices and bills you have been scraping, so they must agree. Write one script that opens each report in turn and proves all four of these:

Hints: every figure carries a data-amount with a clean number, section totals carry data-total="Total Assets" and the like, and account rows carry data-account-code. Compare rounded to two decimals — comparing floats directly will bite you.

Each report rebuilds only when Update is pressed, and Profit and Loss takes a date range while the others take a single as-at date. Your script cannot assume one toolbar shape.

9.2 Now break it on purpose

Create and approve a large invoice, then re-run 9.1. Every figure should move and every check should still pass. If a check fails, work out whether your script is wrong or you have found a real inconsistency — and be honest about which.

This is what a reconciliation automation actually is: not scraping, but proving two independently-produced numbers agree, and saying so loudly when they do not.

Level 10 — Capstone

Ship it

10.1 The month-end pack

One script, run from a single click, that:

  1. collects every overdue invoice across all pages;
  2. groups them by customer with a total per customer;
  3. downloads that as overdue-by-customer.csv;
  4. creates the ten invoices from 5.3 as drafts;
  5. reports at the end: how many it read, how many it created, how long it took, and anything it could not do.

It must survive Slow latency, and it must not leave the ledger half-finished if something goes wrong partway.

10.2 Hand it to somebody else

Turn it into a bookmarklet with a small settings panel — a date, a customer filter, a Run button — so a colleague who does not write code can use it. Then watch somebody else run it without you explaining anything. Whatever they get stuck on is your real bug list.

Finished? Go back and re-run everything from Level 4 down after pressing Reset demo data, with latency on Slow. Anything that only worked the first time was never working.