Skip to Content

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 runHow
Jest or Vitest, in Nodeoffice.js runs in a jsdom window whose parent frame is the twin. Your add-in’s modules call Word.run unchanged.
A browserThe 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:

  1. 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.
  2. 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.
  3. Tracking mode. The Review bar shows changeTrackingMode, with TrackAll labelled For Everyone and TrackMineOnly labelled 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 with rystic 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 new Word.run.
  • Compare by value. Objects and arrays that office.js returns come from the jsdom window, so use toEqual. toStrictEqual rejects them for their prototype.
  • Requirement sets. Office.context.requirements.isSetSupported answers 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 to Test 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:

WorldWhat it is
word_contract_redlinedA 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_cleanThe 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 id is a fixed number, where Word mints a random one each time, and its creationDate is the twin’s clock. Compare comments by content, not id (RYS-2924).
  • getTrackedChanges() on body.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.insertOoxml takes a package Word refuses, and search sent with a plain options object, which office.js never sends, finds hits where Word answers InvalidArgument (RYS-2924).
  • The twin holds the document’s content, not its pages: no pagination, page numbers, headers or footers.

Release notes: Changelog

Last updated on