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.
Level 1 — Read the page
Selectors1.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
Events2.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
Async3.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
Loops4.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:
- your scraped total;
- the Overdue tile on the Dashboard;
- Reporting › Aged Receivables Summary, adding every column except Current.
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 in5.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 out6.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
Repetition7.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
Fragility8.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-checks9.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:
- Balance Sheet: Net Assets equals Total Equity.
- Balance Sheet: Total Assets minus Total Liabilities equals Net Assets.
- Profit and Loss: Net Profit equals the Balance Sheet's Current Year Earnings, for the same period.
- Aged Receivables: the grand total equals Accounts Receivable on the Balance Sheet.
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 it10.1 The month-end pack
One script, run from a single click, that:
- collects every overdue invoice across all pages;
- groups them by customer with a total per customer;
- downloads that as
overdue-by-customer.csv; - creates the ten invoices from 5.3 as drafts;
- 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.