Simulators
Word simulator
word-twin plays Word for the web to your Office.js add-in. The add-in loads Microsoft’s office.js from its CDN, unchanged. Each context.sync() is a batch that the twin answers as Word does, covering paragraphs and ranges, search, tracked changes, comments and OOXML. Your redlining code runs against real Office.js behaviour, with no Microsoft 365 account and a fresh document for every test.
The twin’s harness, @rystic/officejs-harness, runs your suites in either of two ways:
| Where your tests run | How |
|---|---|
| Jest or Vitest, in Node | office.js runs in a jsdom window whose parent frame is the twin. Your add-in’s modules call Word.run unchanged. |
| A browser | The twin’s host page frames your task pane. Playwright or a person clicks through it. |
Quickstart
You need an API key from Account → API keys (how), Node 20 or newer, and network access to appsforoffice.microsoft.com.
curl -fsSL https://www.rystic.ai/install.sh | sh
rystic login rys_… # your API key
rystic run word-twin -d # serves on http://127.0.0.1:8080
npm install --save-dev http://127.0.0.1:8080/officejs/harness.tgz vitest
The twin serves the harness that matches it. Save this test as redline.test.js. It turns tracking on, edits a clause, and asserts both the tracked change and the reviewed text:
import { afterAll, beforeAll, beforeEach, expect, it } from 'vitest';
import { connect } from '@rystic/officejs-harness';
let office;
beforeAll(async () => {
office = await connect(); // office.js from Microsoft's CDN, the twin as its host
office.install(); // Office, OfficeExtension and Word as globals
}, 60000);
beforeEach(() => office.reset()); // a fresh document for every test
afterAll(() => office.close());
it('tracks a clause edit and reads the redline both ways', async () => {
await Word.run(async context => {
const body = context.document.body;
body.insertParagraph('The Supplier shall deliver the Goods within 30 days of the order.', 'End');
context.document.changeTrackingMode = Word.ChangeTrackingMode.trackAll;
const hits = body.search('30 days', { matchCase: true });
hits.load('items');
await context.sync();
hits.items[0].insertText('45 days', 'Replace');
await context.sync();
});
const result = await Word.run(async context => {
const changes = context.document.body.getTrackedChanges();
changes.load('items/type,items/text');
const whole = context.document.body.getRange('Whole');
const current = whole.getReviewedText('Current');
const original = whole.getReviewedText('Original');
await context.sync();
return {
changes: changes.items.map(c => ({ type: c.type, text: c.text })),
current: current.value,
original: original.value,
};
});
expect(result.changes).toEqual([
{ type: 'Added', text: '45 days' },
{ type: 'Deleted', text: '30 days' },
]);
expect(result.current).toContain('within 45 days of the order.');
expect(result.original).toContain('within 30 days of the order.');
});
npx vitest run redline.test.js
# -> ✓ redline.test.js (1 test)
If the twin runs elsewhere, set RYSTIC_TWIN_URL, for example RYSTIC_TWIN_URL=http://127.0.0.1:8090 npx vitest run. In a real suite, your add-in’s own modules make the Word.run calls; the test imports and calls them.
word-twin is on Pro and Enterprise, not Free (plans): on a Free key, rystic run word-twin is refused with product_not_in_plan.
How it plays Word
Your add-in never talks to Word’s document directly. office.js builds a batch for every context.sync(), posts it to its host frame, and turns the answer into proxies, loaded properties and errors. The twin is that host: it answers the handshake as Word for the web does and each batch as POST /officejs/ProcessQuery. Everything on the client side stays Microsoft’s — batching, proxies, load(), track() and OfficeExtension.Error — so a test exercises your code and Microsoft’s library together. The twin declares WordApi 1.6 and WordApiSearchFlags 1.1, the requirement sets whose members it serves, so an add-in that checks isSetSupported before a newer member takes its fallback.
It serves 86 members of Word’s API: the document and its body, paragraphs and ranges with text, search, insertText, insertParagraph, clear and delete; changeTrackingMode; tracked changes with accept, reject, acceptAll and rejectAll; getReviewedText; comments with insertComment, reply, resolved and delete; and getOoxml and insertOoxml. As in Word, every change of one getTrackedChanges() load stays addressable while others are accepted or rejected, so accepting each of one author’s changes in turn works. A member outside that set answers Word’s ApiNotFound: tables, lists, content controls, fields, styles and formatting, images, headers and footers, the selection and events are not served.
Jest
Jest’s default node environment works the same way. Pass beforeAll a hook timeout, because office.js takes a few seconds to load from its CDN:
const { connect } = require('@rystic/officejs-harness');
let office;
beforeAll(async () => { office = await connect(); office.install(); }, 60000);
beforeEach(() => office.reset());
afterAll(() => office.close());
In a browser
office.js trusts a host without prompting only when the host page is served over https, on the add-in’s own hostname. The twin’s host page is /officejs/host?addin=<your task pane URL>, and the harness fronts it in either of two ways.
With Playwright, openAddin routes the host page and your built files inside the browser context, so no certificate is needed:
const { chromium } = require('playwright-core');
const { openAddin } = require('@rystic/officejs-harness/browser');
const browser = await chromium.launch();
const { frame } = await openAddin(await browser.newContext(), {
root: 'dist', addin: 'https://localhost:3000/taskpane.html',
});
await frame.getByRole('button', { name: 'Redline' }).click();
By hand, officejs-harness serve opens an https front on localhost with the certificate that npx office-addin-dev-certs install trusts:
npx officejs-harness serve --root dist # your built add-in
npx officejs-harness serve --addin https://localhost:3000/taskpane.html # your dev server
Open the URL it prints.
Your own dev server can also front the twin: proxy /officejs to http://127.0.0.1:8080, then open https://localhost:3000/officejs/host?addin=https://localhost:3000/taskpane.html.
Watch the document
Open http://127.0.0.1:8080/_rystic/ui in a browser while your add-in runs. The Document card shows the twin’s document the way Word’s review view does, and redraws as each context.sync() lands:
- Redlines. Insertions are underlined and deletions struck through, each in its author’s colour, with a change bar beside every changed paragraph. A revision that just arrived flashes once.
- Comments. Threads and their replies sit in the margin beside the line they anchor on. Resolved threads are dimmed, and hovering a thread highlights its text.
- Tracking mode. The Review bar shows
changeTrackingMode, withTrackAlllabelled For Everyone andTrackMineOnlylabelled Just Mine, alongside the change and comment counts and each author’s colour.
The card shows the document’s content, not Word’s page layout: there is no pagination, no headers or footers, and no page numbers. It only reads. It draws GET /_rystic/inspect?view=document, the twin’s own JSON reading of the document, so it adds no batches to your add-in’s traffic. A test can read that view too.
Things to know
- One twin is one document. Run suites that share a twin one file at a time (Vitest
fileParallelism: false, Jest--runInBand), or start a twin per worker withrystic run word-twin -d --port 0. reset()starts a fresh document. Office.js objects from before a reset are no longer valid. Create new ones in a newWord.run.- Compare by value. Objects and arrays that
office.jsreturns come from the jsdom window, so usetoEqual.toStrictEqualrejects them for their prototype. - Requirement sets.
Office.context.requirements.isSetSupportedanswers only for the sets the twin serves. An add-in that checks for a set before using it sees what the twin covers. - Every edit is
Test User’s. Edits made while tracking is on are attributed toTest User; a seeded document’s revisions and comments keep their own authors and dates.
Contracts to start from
Without --scenario the twin opens a blank document. Two worlds ship, each on 2026-02-02:
| World | What it is |
|---|---|
word_contract_redlined | A mutual NDA mid-negotiation: Alice’s and Bob’s tracked insertions and deletions, Alice’s comment on the counterparty with Bob’s reply, and a resolved comment. |
word_contract_clean | The same NDA with every change accepted and no comments, to redline from scratch. |
rystic run word-twin -d --scenario word_contract_redlined
A test loads one without a restart through POST /_rystic/scenario, or starts from your own contract through POST /_rystic/seed, which takes the document as a base64 .docx:
import { readFileSync } from 'node:fs';
beforeEach(async () => {
await office.reset();
const docx = readFileSync('fixtures/msa.docx').toString('base64');
await fetch('http://127.0.0.1:8080/_rystic/seed', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ state_document: { main: { docx } } }),
});
});
The seed’s revisions, comments, authors and dates come through as written. Every run from the same seed gives the same document: the twin’s clock starts where the world pins it and never moves by wall time, so two identical runs give byte-identical OOXML.
How it’s measured
Each of the twin’s 86 members has a probe that runs the same Office.js batches against the twin and against Word for the web, through office.js’s own request executor in a signed-in Word session. The full-suite round of 2026-10-14 (20261014T014904Z) matched 74 of 86, none inconclusive.
Known issues
- A new comment’s or reply’s
idis a fixed number, where Word mints a random one each time, and itscreationDateis the twin’s clock. Compare comments by content, not id (RYS-2924). getTrackedChanges()onbody.getRange('Whole')lists an insertion Word leaves out of that range; the body’s and each paragraph’s lists agree with Word (RYS-2924).Paragraph.insertOoxmltakes a package Word refuses, andsearchsent with a plain options object, whichoffice.jsnever sends, finds hits where Word answersInvalidArgument(RYS-2924).- The twin holds the document’s content, not its pages: no pagination, page numbers, headers or footers.
Release notes: Changelog