# Word simulator

Run your Office.js add-in's tests against a local Word host — real office.js, tracked changes, comments and reviewed text.

`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](https://www.rystic.ai/account/api-keys) ([how](https://www.rystic.ai/docs/licensing.md#get-an-api-key)), Node 20 or newer, and network access to `appsforoffice.microsoft.com`.

```bash
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:

```js
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.');
});
```

```bash
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](https://www.rystic.ai/docs/licensing.md#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:

```js
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:

```js
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:

```bash
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:

| 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. |

```bash
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`:

```js
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](https://www.rystic.ai/docs/changelog.md#word)

---

Source: https://www.rystic.ai/docs/simulators/word · Markdown: https://www.rystic.ai/docs/simulators/word.md
