OpenAI announced ChatGPT Work on July 9th, and have been furiously iterating on it ever since. It is an extraordinarily confusing and very powerful product. Here's what I've figured out about it so far.
The more interesting version of ChatGPT Work is the one that runs in the cloud. This can be accessed via chatgpt.com or through the ChatGPT mobile apps. Let's call it Work Cloud.
If you install the ChatGPT desktop app - the app that used to be called Codex - you gain access to a thing called ChatGPT Work that can access files and run programs directly on your computer. Let's call that one Work Local. This one feels more like regular Codex re-skinned to be less intimidating to non-software-developers.
For the rest of this article I'm going to talk exclusively about Work Cloud.
Right now, ChatGPT Work (in both flavors) is available only to $20/month and up subscribers. Free users and $8/month Go users do not have access.
The interface for accessing Work is a tab selector, which presents it as an alternative to Chat:

The obvious question is when should I use Chat, and when should I use Work?
OpenAI's official answer to that question is:
Use Chat when you want an answer, explanation, brainstorm, or short draft. Use ChatGPT Work when you want ChatGPT to complete a task with a clear outcome, such as a brief, deck, analysis, recurring update, workflow, or file you can review and use.
I find that almost entirely useless, because I've been using regular ChatGPT Chat for all of those task categories for years!
The better question then is what features does Work have that are missing from Chat?
After extensive experimentation I think I've mostly figured that out:
In Work, you get the option to pick GPT-5.6 Sol, Luna, or Terra, each with Light, Medium, High, Extra High, Max, or Ultra reasoning levels. You can also pick GPT-5.5 at Light, Medium, High, or Extra High.
These look to be the same models that are available through the OpenAI API.
Chat offers a different selection: 5.6 Instant, Medium, High, Extra High, and Pro (actually Extra High and Pro are only available for $100/month+ subscribers - $20/month subscribers cap out at High). It doesn't explain if those are Luna or Terra or Sol (I'm assuming Sol?). 5.6 Pro appears to be exclusive to Chat, with no equivalent in Work.
My current understanding from using Codex is that Ultra is a special mode that more eagerly delegates to sub-agents.
I believe ChatGPT Work sessions are billed against your Codex allowance, while ChatGPT Chat Sessions get their own, separate allowance. This may help explain the model availability differences.
As a long-time fan of the Code Interpreter pattern - pioneered by OpenAI in 2023 - this is by far the most exciting feature of ChatGPT Work (Cloud) for me.
The code execution environment can now talk to the rest of the internet!
ChatGPT Chat can't do this - if you ask it to install additional software packages or interact with websites or APIs that access will be blocked by the container proxy.
(Weirdly, back in January it grew the ability to install packages, but that doesn't seem to work any more. I wish they had better changelogs!)
Claude's equivalent container has allowed restricted internet access since it launched last September. Claude can install packages from PYPI and NPM and clone repositories from GitHub. But that is about it: the allowlist of domains is very short.
ChatGPT Work allows a whole lot more than that. It can be configured with a specific list of allowed domains, but the default appears to be open to all.
This makes Work an incredibly useful tool. You can have it clone GitHub repositories, install their dependencies, then use them to interact with the rest of the web!
Another killer feature of ChatGPT Work is the browser tool. ChatGPT Work can launch a full Chrome instance, load websites, fill out forms, and take screenshots.

If a site requires sign in the browser can prompt you to take over and enter both passwords and 2FA codes, without round-tripping those credentials through the model itself.
It can even run JavaScript against the DOM of loaded pages. I prompted:
Load simonwillison.net in your browser and extract the headings using JavaScript
ChatGPT Work fired up a browser instance and ran the code:
await tab.playwright.evaluate(() => {
return Array.from(document.querySelectorAll("h1,h2,h3,h4,h5,h6"), heading => ({
level: heading.tagName.toLowerCase(),
text: heading.innerText.trim().replace(/\s+/g, " "),
id: heading.id || null
}));
});This feels a lot like my shot-scraper javascript tool, only now I can access it on my phone!
ChatGPT Chat gets a fresh filesystem for each chat session. These cannot be accessed from any other session.
In ChatGPT Work each session gets its own scratch folder - named something like /workspace/scratch/e00a0a017944 - but each of those are persisted across sessions, so you can access files from previous chats. I have 171 folders in /workspace/scratch right now!
As far as I can tell that /workspace volume is mounted to all Work sessions that are currently running - file edits from one can be instantly seen by the others. They don't seem to share the same process space though, and localhost servers running in one can't be accessed from another.
ChatGPT Work has the ability to build and deploy entire websites, using Cloudflare Workers. These can have HTML and JavaScript and can run server-side features too, including stateful features on top of Cloudflare D1 and R2.
Here's a simple site I built with this feature:
london-pelicans-in-her-piety.simonw.chatgpt.site

My prompt was:
Figure out all of the places in London with a pelican in her piety, then turn that into a JSON file and build a ChatGPT sites site about them
(A pelican in her piety is a fascinating piece of medieval Christian imagery - once you know about them you'll find them all over the place.)
These sites default to being private to the user that created them, but you can make them public and (on team plans) share them with other specific individuals.
There's not much to say about this one. ChatGPT Chat can't run sub-agents. ChatGPT Work can. This is very much a power-user feature: if you are running a complex project that can benefit from multiple parallel agents working together, Work can do that.
Another feature that seems to have migrated from regular ChatGPT to ChatGPT Work at some point. You can prompt ChatGPT Work like this:
run a search to see if Waymo have announced a launch date for Half Moon Bay every day at 8am
This will schedule a prompt to run on that frequency. These prompts can decide that nothing interesting has happened, or they can decide to notify you of some new information.
Update: Actually this seems to work in ChatGPT Chat as well.
It's still worth noting here though, as it can be used in conjunction with other ChatGPT Work exclusive features. You can set a scheduled task to update a ChatGPT Site on an hourly basis, for example.
An open question for me right now is how safe all of this stuff is.
My lethal trifecta model warns about the risks inherent in any agent system that combines access to private data with exposure to untrusted content and a way to communicate stolen information back to an attacker.
ChatGPT Work combines all three!
I'd love to hear more from OpenAI about how they protect ChatGPT Work sessions against prompt injection attacks. I expect their answer is the same auto-review mechanism as Codex.
Figuring this all out took way more work than it should have.
I think there are two key problems here:
If the ChatGPT Work documentation included the exact system prompt and tool descriptions used by the agent I wouldn't have needed to write this post.
Tags: ai, openai, generative-ai, chatgpt, llms, code-interpreter, lethal-trifecta, general-agents
Activity percentages on time-tracking dashboards are basically a compressed view of mouse and keyboard input. Hardware jigglers, software “movers,” and keep-awake scripts exist because they game that view: they keep the green bar green without proving anyone is working. Richer Windows endpoint data—apps, session state, files, network—can make fake presence look thin. Correlation still isn’t proof. If your company treats activity % as honesty, you’re measuring the wrong thing.
This got loud when remote and hybrid work turned “presence” into a management proxy. Billing fights, contractor timesheets, and always-on Slack status rewarded meters that look objective. A cheap USB gadget that nudges the cursor—or a script that fakes input—isn’t a clever attack on your stack. It’s a rational response to a bad metric. The dashboard isn’t broken. The metric was never presence.
Most arguments about idle time and jigglers start the same way: leaders ask three different questions and expect one number to answer all of them.
Activity % mostly answers question one—and not well. It can’t tell a human hand from a HID gadget that looks like a mouse, or from injected events. It also can’t tell “reading a long design doc on a second monitor” from “away from the desk.” Pure idle rules punish real work: light typing on video calls, long compiles, research that’s mostly scroll-and-think.
If the risk is payroll honesty, you need more than input volume. If the risk is data loss, mouse motion barely matters. Smash those goals into one green percentage, and jigglers win.
A typical desktop agent for time tracking does some version of this:
HID / OS input events
↓
Desktop agent
↓
Time buckets (e.g. 1–10 min)
↓
Activity percent
↓
Dashboard
Windows already has basics for input idle detection. Microsoft’s GetLastInputInfo returns the time of the last input event for the session that called it—not magic proof that “the employee is working.” Agents may use last-input APIs, raw input hooks, or their own sampling of moves and keys. Same bottom line either way: the meter counts motion in a bucket, then paints a percentage.
That percentage does not prove:
Jigglers win because they feed the same event class the meter counts. You don’t need a novel exploit. You need the sensor’s blind spot.
| Class | What it fakes | What it usually fails to fake |
|---|---|---|
| USB HID “mouse” / mover | Steady move or click | App switching, file/network patterns, normal task hopping |
| Software mover / script | Periodic cursor wiggles or fake input | Natural timing, real typing, a believable app story |
| OS keep-awake / sleep blockers | Stops sleep / dimming | High activity % on its own—unless you also spoof input |
This is a cheat sheet for ops and security, not a recipe book. The story is already public: banks and big employers have fired people over simulated keyboard activity and “mouse movers,” including Wells Fargo, as reported by the BBC. The takeaway isn’t “buy a detector.” It’s that if you treat input as proof of work, you create demand for tools that fake input.
Some monitoring vendors pitch dedicated “jiggler detection.” Treat that as marketing until you know which signals they use. Motion-only heuristics are brittle. Multi-signal context is where suspicion gets useful.
Once you instrument a Windows PC beyond “how much did the mouse move,” you get a stack. Each layer does a different job. None of them is a courtroom.
| Layer | Examples | Useful against jigglers? |
|---|---|---|
| Input volume only | Move/click/key counts | No—this is what jigglers target |
| Session state | Lock/unlock, console vs remote, idle timeout | Somewhat—locked + “active” deserves a look |
| Process / app timeline | Foreground app, launch/exit | Yes—hours “active” on one junk process looks thin |
| Web / search (sensitive) | URLs, queries | Yes—heavy on privacy; policy first |
| File / USB / print / clipboard | Channel activity | Strong for fraud and DLP; different goal than time cheating |
| Network | Interfaces, per-app traffic | Helps separate real work from empty motion |
| Screens / live view | Spot checks | Human review; too sensitive for first-line payroll fights |
| Anomaly / rule hits | Alerts in a queue | Ops workflow—not a magic classifier |
Hard truth: more signals raise suspicion. They don’t prove guilt. A quiet engineer on a call can look “idle.” A jiggler with a real IDE open can look “busy.” Treat multi-signal outliers as a review queue, not an auto-fire pipeline.
A simple investigation pattern:
Watch session boundaries too. Last-input APIs are session-specific. RDP and multi-session hosts (terminal servers / RDS) make “was this the interactive user?” a real engineering problem, not a dashboard footnote. On shared Windows hosts, per-session context beats one PC-wide activity bar. Sample the wrong session and you’ll mark someone idle forever—or credit them for activity that wasn’t theirs.
For the longer arc—from punch clocks to screenshots to “work intelligence”—see this Jon Stojan time tracking piece on HackerNoon. Vendors keep renaming the dashboard. The mistake stays the same when one thin sensor becomes a moral score.
Security teams that care about insider risk already think in layers. NIST SP 800-53 talks about continuous monitoring and audit as control families—not as one productivity percentage. Steal that mindset: pick controls that match the risk, then store as little as you can. Need attendance trends? Don’t keep keystroke content. Need DLP? Don’t pretend mouse percentages are your control set.
Obsessing over “catch jigglers” creates workplace theater. People learn to look busy. Trust drops. Then you get a second arms race: more invasive capture vs. smarter spoofing.
A healthier setup starts with a written policy:
Transparent monitoring (people know the scope) and stealth modes are policy choices, not moral absolutes. Time-tracking buyers usually want softer defaults. Security buyers often need channel visibility—files, USB, web—for insider risk. One product toggle won’t fix both cultures. That’s how you end up with dashboards that punish people who read and miss people who leak.
There’s a security angle that doesn’t get enough airtime: unknown USB HID devices on managed laptops aren’t just a “productivity cheat.” They’re an endpoint hygiene problem. If you already lock down flash drives, treat mystery HID gadgets with similar seriousness. That doesn’t mean hunting every accessibility tool or specialty mouse. It means inventory, allowlisting, and clear exceptions.
This isn’t legal advice. Notice, minimization, and retention rules vary by place. Put the employee-facing policy in writing before you turn agents on.
Mouse jigglers still fool time-tracking dashboards because those dashboards often measure input motion, then ask managers to treat the number as work. Endpoint telemetry across apps, session state, files, and network can make fake presence look thin—but only as correlation, and only if you respect false positives.
Measure the behavior that matches the business risk, not the easiest sensor.
What single metric does your company still treat as truth—and what would you replace it with? Drop it in the comments.
Read more of this story at Slashdot.
If you survey ten seasoned Flutter developers about how they structure networking in production, you will almost certainly see the same multi-tiered pipeline:
┌────────────────────────────────────────────────────────────────────────┐
│ Traditional Flutter Networking Pipeline │
├────────────────────────────────────────────────────────────────────────┤
│ [Dio / HTTP Client] ─▶ [API Service] ─▶ [Repository Layer] ─▶ │
│ [Cubit / BLoC] ─▶ [UI Builders & Banners] │
└────────────────────────────────────────────────────────────────────────┘
The underlying architectural principles are sound: separation of concerns, testability, and isolating network transport details from UI widgets.
However, in practice, this classical layered stack demands an enormous amount of repetitive boilerplate:
Initial, Loading, Success(data), Failure(error)) or union types for every single API endpoint.CancelToken plumbing or heavy rxdart switchMap streams.With bloc_signals, we can preserve complete separation of concerns while eliminating 70% of the friction.
Let us examine how to architect a modern, clean, production-ready networking layer using CubitSignalMixin, HydratedMixin, and .toAsyncBlocSignal().
.toAsyncBlocSignal()
In many cases, a feature only needs to fetch data from an endpoint and present it in the UI with loading and error states.
Instead of writing a custom Cubit and four distinct state classes, any Dart Future<T> converts directly into a BlocSignalBase<AsyncState<T>> with a single method call:
class UserProfileService {
UserProfileService(this._dio);
final Dio _dio;
Future<UserProfile> fetchUserProfile(String userId) async {
final response = await _dio.get('/users/\$userId');
return UserProfile.fromJson(response.data as Map<String, dynamic>);
}
}
In your presentation layer or view model:
// Converts Future<UserProfile> into a synchronous BlocSignalBase<AsyncState<UserProfile>>
final userProfileBloc = profileService
.fetchUserProfile('user_123')
.toAsyncBlocSignal();
Because AsyncState<T> is a sealed class hierarchy (AsyncLoading, AsyncData, AsyncError), you get compile-time exhaustive pattern matching in your Flutter widgets:
BlocSignalBuilder<BlocSignalBase<AsyncState<UserProfile>>, AsyncState<UserProfile>>(
bloc: userProfileBloc,
builder: (context, state) => switch (state) {
AsyncLoading() => const Center(
child: CircularProgressIndicator(),
),
AsyncData(:final value) => ProfileDetailsView(user: value),
AsyncError(:final error) => ErrorCard(
message: error.toString(),
),
},
)
No custom state classes, no manual try/catch event plumbing, and no FutureBuilder rebuild bugs.
CubitSignalMixin & HydratedMixin
In enterprise apps, repositories often need to extend an existing API client base class (such as BaseApiClient or AuthenticatedHttpService) while maintaining persistent local caches.
Because Dart only permits single inheritance, traditional repositories could not be state containers.
With CubitSignalMixin and HydratedMixin, your repository IS the reactive, persistent state container:
import 'package:bloc_signals/bloc_signals.dart';
import 'package:bloc_signals_hydrate/bloc_signals_hydrate.dart';
import 'package:dio/dio.dart';
/// A production domain repository extending BaseApiClient with 0ms reactivity & disk caching!
class ProductRepository extends BaseApiClient
with
CubitSignalMixin<AsyncState<List<Product>>>,
HydratedMixin<AsyncState<List<Product>>> {
ProductRepository(super.dio) {
// 1. Initialize reactive signal container
initCubitSignal(initialState: const AsyncLoading());
// 2. Initialize Frame-1 persistent storage cache
initHydrated(storageKey: 'cached_products_v1');
}
/// Refreshes data from the network using toFutureSignal to eliminate try-catch
Future<void> refresh() async {
emit(const AsyncLoading());
// ⚡ toFutureSignal automatically projects success into AsyncData and exceptions into AsyncError!
final fetchSignal = _fetchProducts().toFutureSignal();
await fetchSignal.future;
emit(fetchSignal.value);
}
Future<List<Product>> _fetchProducts() async {
final response = await dio.get('/products');
final rawList = response.data as List<dynamic>;
return [
for (final item in rawList) Product.fromJson(item as Map<String, dynamic>),
];
}
// 💾 Pattern matching for instant offline hydration:
@override
Object? toJson(AsyncState<List<Product>> state) => switch (state) {
AsyncData(:final value) => [
for (final product in value) product.toJson(),
],
_ => null,
};
@override
AsyncState<List<Product>>? fromJson(dynamic json) => switch (json) {
{'products': List<dynamic> list} => AsyncData([
for (final item in list) Product.fromJson(item as Map<String, dynamic>),
]),
List<dynamic> list => AsyncData([
for (final item in list) Product.fromJson(item as Map<String, dynamic>),
]),
_ => null,
};
}
HydratedMixin restores the cached List<Product> synchronously before the first pixel renders. Zero loading flickers.repository.refresh() executes network I/O and updates the UI synchronously on emit(AsyncData(freshProducts)).BaseApiClient without needing an intermediate wrapper or proxy Cubit.restartable()
One of the most insidious bugs in mobile networking is the out-of-order response.
If a user searches for "Fl", then "Flu", and finally "Flutter", the network request for "Fl" might take 800ms while "Flutter" takes 200ms. Without cancellation, the "Fl" response resolves last and overwrites the screen with stale data!
With BlocSignalMixin, solving this requires zero cancel tokens or Rx streams—just apply the built-in restartable() transformer:
sealed class SearchEvent {
const SearchEvent();
}
final class SearchQueryChanged extends SearchEvent {
const SearchQueryChanged(this.query);
final String query;
}
class SearchRepository extends BaseApiClient
with
CubitSignalMixin<AsyncState<List<SearchResult>>>,
BlocSignalMixin<SearchEvent, AsyncState<List<SearchResult>>> {
SearchRepository(super.dio) {
initCubitSignal(initialState: const AsyncData([]));
// ⚡ Built-in concurrency control: automatically aborts prior in-flight queries!
on<SearchQueryChanged>((event, emit) async {
final query = event.query.trim();
if (query.isEmpty) {
emit(const AsyncData([]));
return;
}
emit(const AsyncLoading());
final searchSignal = _executeSearch(query).toFutureSignal();
await searchSignal.future;
emit(searchSignal.value);
}, transformer: restartable());
}
Future<List<SearchResult>> _executeSearch(String query) async {
final response = await dio.get('/search', queryParameters: {'q': query});
final results = response.data as List<dynamic>;
return [
for (final item in results) SearchResult.fromJson(item as Map<String, dynamic>),
];
}
}
┌──────────────────────────────┬────────────────────────┬────────────────────────┐
│ Architectural Concern │ Traditional Flutter │ BlocSignal Ecosystem │
├──────────────────────────────┼────────────────────────┼────────────────────────┤
│ Async State Representation │ 4 custom classes/enums │ AsyncState<T> sealed │
│ In-Flight Race Conditions │ Dio CancelToken / Rx │ restartable() builtin │
│ Duplicate Tap Protection │ Custom boolean flags │ droppable() builtin │
│ Frame-1 Offline Persistence │ SQLite / SharedPreferences│ HydratedMixin frame-1│
│ Existing Base Class Interop │ Proxy/Wrapper classes │ CubitSignalMixin │
└──────────────────────────────┴────────────────────────┴────────────────────────┘
By pairing pure Dart reactive signal primitives with composable mixins and higher-order concurrency transformers, your networking layer remains clean, testable, and robust—with a fraction of the traditional ceremony.
How do you currently handle cancellation, offline caching, and async state in your Flutter networking layer?
Share your architecture setups and thoughts in the comments below!
TL;DR - create a work manager rule to make your tests easier to write. It’s not hard to do, and it pays off.
I have been working quite a bit with WorkManager stuff these days, and while attempting to write tests for them, I realised there were things I was doing repeatedly.
Work Manager provides a helpful testing library - androidx.work:work-testing and they have a very nice guide on how to write tests for work manager - both integration tests, and testing worker implementation details1, and despite this, I found myself writing a couple of things over and over again.
This post is somewhere between bringing awareness to testing APIs available for WorkManager, and showcasing a test rule that I think can help lower the barrier to testing.
Say I have a SyncDispatcher class that does sync, and sometimes, I want to keep existing work, and other times, I want to replace the existing work.
class SyncDispatcher(val workManager: WorkManager) {
fun sync(params: SyncParams) {
val existingWorkPolicy = if (params.syncAll) {
ExistingWorkPolicy.REPLACE
} else {
ExistingWorkPolicy.APPEND_OR_REPLACE
}
val workRequest = OneTimeWorkRequestBuilder<SyncWorker>()
.setConstraints(Constraints.Builder().setRequiredNetworkType(NetworkType.CONNECTED).build())
.build()
workManager.beginUniqueWork("sync-work", existingWorkPolicy, workRequest)
.enqueue()
}
}
If I wanted to test this behaviour in an “integration-testing” style, I could use the TestDriver API2 of work manager to instrument the constraints that my work depends on, be it initial delay, network condition, period delay (for perioidic work), stopping the work with a reason, and so on.
// SyncDispatcherTest.kt
@Before {
val config = Configuration.Builder().setExecutor(SynchronousExecutor()).build()
WorkManagerTestInitHelper.initializeTestWorkManager(testContext, config)
}
@Test
fun `sync all drops all prior sync requests`() = runTest() {
// given that we have previous sync work enqueued
dispatch.sync(syncPartialParams)
// when we receive a refresh all
dispatch.sync(syncAllParams)
// then when all constraints are met
val requests = WorkManager.getInstance(context)
.getWorkInfosForUniqueWork("sync-work")
.get()
val driver = WorkManagerTestInitHelper.getTestDriver(context)
requests.forEach { driver?.setAllConstraintsMet(it.id) }
// then we verify that the syncer recorded only the "sync all" params
assertEquals(
listOf(syncAllParams),
fakeSyncer.recordedSyncParams,
)
}
By the time I want test various combinations of work state, like failed sync, retries, etc, I will be doing a lot of these checks queries, and driver calls. Then, when I want to do it for another worker, I have to do the same scaffolding - initialising the work manager test init helper, and execute various APIs to enqueue work and query the work state.
Over time, I have found that these operations were finite, in some sense. I typically would do things like: make a certain work run (whether by tag, or unique name, or by id), confirm that a certain work is cancelled, and so on.
So, naturally, I started thinking about how to stop writing all these things over, without creating a BaseWorkManagerTest, because test rules are better for composition, than a base test class3.
I thought about simple top-level functions with the convenience APIs I wanted. That would work, but I would still have to copy-pasta a lot of the work manager test initialisation and test cleanup, and the retrieval API. Furthermore, I did not like that the top-level functions would not be scoped to anything.
I understand that it may be hard to find an API that covers everyone’s use-cases, so your mileage may vary, but I think an API like this should exist.
The rule itself is not a lot, the problems I wanted to solve were:
TestDriver to modify the work state, and some involved querying the work manager directly to verify the state. I would love a unified API to mess with work manager in my test environment.class WorkManagerTestRule(
private val context: Context = InstrumentationRegistry.getInstrumentation().targetContext
) : ExternalResource() {
private val executor = SynchronousExecutor()
/**
* [Configuration.Builder] for the [WorkManager] used in the test.
*
* The default value sets the executor to be the [SynchronousExecutor]. To add more config or
* change the builder, do so before your test setup returns.
*/
var configBuilder: Configuration.Builder =
Configuration.Builder().setExecutor(executor).setTaskExecutor(executor)
val driver: TestDriver? by lazy {
WorkManagerTestInitHelper.getTestDriver(context)
}
val workManager by lazy {
WorkManager.getInstance(context)
}
override fun before() {
super.before()
WorkManagerTestInitHelper.initializeTestWorkManager(context, configBuilder.build())
}
override fun after() {
super.after()
WorkManagerTestInitHelper.closeWorkDatabase()
}
}
If you’re using JUnit 5 already in your project (I’m jealous), then you can convert that into an extension and applying the corresponding test lifecycle callbacks.
To solve the problem of convenience APIs, I created a bunch of helper methods that mirror what I tend to do often.
/**
* Enqueues a [WorkRequest]. The work request only runs if there are no constraints or all the
* constraints are met.
*
* Shortcut for [WorkManager.enqueue]
*/
fun WorkManagerTestRule.enqueue(request: WorkRequest): Operation {
return workManager.enqueue(request)
}
/**
* Enqueues a list of [WorkRequest]s. The work requests only run if there are no constraints or all
* the constraints are met.
*
* Shortcut for [WorkManager.enqueue]
*/
fun WorkManagerTestRule.enqueue(requests: List<WorkRequest>): Operation {
return workManager.enqueue(requests)
}
/**
* Enqueues a [WorkRequest], and then meets its constraints to execute it.
*
* Shortcut for [WorkManager.enqueue] and [TestDriver.setAllConstraintsMet]
*/
fun WorkManagerTestRule.execute(request: WorkRequest) {
with(request) {
workManager.enqueue(this)
driver?.setAllConstraintsMet(id)
}
}
/**
* Enqueues a list of [WorkRequest]s, and then meets all their constraints to execute them.
*
* Shortcut for [WorkManager.enqueue] and [TestDriver.setAllConstraintsMet]
*/
fun WorkManagerTestRule.execute(requests: List<WorkRequest>) {
requests.forEach { request ->
execute(request)
}
}
/** Executes all work that match the given [query] by meeting all their constraints to. */
fun WorkManagerTestRule.execute(query: WorkQuery) {
val infos = workManager.getWorkInfos(query).get()
infos.forEach { setAllConstraintsMet(it.id) }
}
/**
* Sets all constraints on the WorkManager work with the given [workSpecId]. Shortcut for
* [TestDriver.setAllConstraintsMet]
*/
fun WorkManagerTestRule.setAllConstraintsMet(workSpecId: UUID) {
driver?.setAllConstraintsMet(workSpecId)
}
I couldn’t possibly figure out whatever everyone would like to do, so I decided to expose the driver, and the work manager as well, and if there’s some operation that the rule does not support, you could write your own extension and implement it.
The test rule helps me to hide the complexities involved in the work manager lifecycle - which in itself is complex, and tends to bring the complexity into the test code, and I suspect this is why I haven’t seen a lot of these integration tests in the project I’m working on.
With the test rule, the original SyncDispatcher scenario then looks like this:
// SyncDispatcherTest.kt
-@Before {
- val config = Configuration.Builder().setExecutor(SynchronousExecutor()).build()
- WorkManagerTestInitHelper.initializeTestWorkManager(testContext, config)
-}
+@get:Rule
+val workManagerTestRule = WorkManagerTestRule(context)
@Test
fun `sync all drops all prior sync requests`() = runTest() {
// given that we have previous sync work enqueued
dispatch.sync(syncPartialParams)
// when we receive a refresh all
dispatch.sync(syncAllParams)
// then when all constraints are met
- val requests = WorkManager.getInstance(context)
- .getWorkInfosForUniqueWork("sync-work")
- .get()
- val driver = WorkManagerTestInitHelper.getTestDriver(context)
- requests.forEach { driver?.setAllConstraintsMet(it.id) }
+ val syncWorkQuery = WorkQuery.Builder.fromUniqueWorkNames(listOf("sync-work")).build()
+ workManagerTestRule.execute(syncWorkQuery)
// then we verify that the syncer recorded only the "sync all" params
assertEquals(
listOf(syncAllParams),
fakeSyncer.recordedSyncParams,
)
}
By the time you apply this to all the work manager states and behaviour you may be testing, all the other workers, etc, this simple rule starts to pay off in terms of test code size and complexity.
WorkManager TestDriver API for instrumenting the work manager workers ↩
WorkManagerTestRule - my take on the missing WorkManager test rule ↩