appium-mcp 1.5.0 → 1.6.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,17 @@
1
+ version: 2
2
+ updates:
3
+ - package-ecosystem: npm
4
+ directory: "/"
5
+ schedule:
6
+ interval: daily
7
+ time: "11:00"
8
+ open-pull-requests-limit: 10
9
+ - package-ecosystem: "github-actions"
10
+ directory: "/"
11
+ schedule:
12
+ interval: daily
13
+ time: "11:00"
14
+ open-pull-requests-limit: 10
15
+ commit-message:
16
+ prefix: "ci"
17
+ include: "scope"
@@ -13,16 +13,20 @@ jobs:
13
13
 
14
14
  steps:
15
15
  - name: Checkout code
16
- uses: actions/checkout@v4
16
+ uses: actions/checkout@v6
17
17
 
18
18
  - name: Setup Node.js
19
- uses: actions/setup-node@v4
19
+ uses: actions/setup-node@v6
20
20
  with:
21
21
  node-version: 'lts/*'
22
22
  cache: 'npm'
23
23
 
24
+ - uses: SocketDev/action@v1
25
+ with:
26
+ mode: firewall-free
27
+
24
28
  - name: Install dependencies
25
- run: npm install --no-package-lock --force
29
+ run: sfw npm install --no-package-lock --force
26
30
 
27
31
  - name: List installed packages
28
32
  run: npm list --depth=0
@@ -1,15 +1,10 @@
1
1
  name: Conventional Commits
2
2
  on:
3
3
  pull_request:
4
-
4
+ types: [opened, edited, synchronize, reopened]
5
5
 
6
6
  jobs:
7
7
  lint:
8
- name: https://www.conventionalcommits.org
9
- runs-on: ubuntu-latest
10
- steps:
11
- - uses: beemojs/conventional-pr-action@v3
12
- with:
13
- config-preset: angular
14
- env:
15
- GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
8
+ uses: appium/appium-workflows/.github/workflows/pr-title.yml@main
9
+ with:
10
+ config-preset: angular
@@ -15,7 +15,7 @@ jobs:
15
15
  build:
16
16
  runs-on: macos-latest
17
17
  steps:
18
- - uses: actions/checkout@v5
18
+ - uses: actions/checkout@v6
19
19
  with:
20
20
  submodules: recursive
21
21
  fetch-depth: 0 # Fetch all tags for version detection
@@ -23,7 +23,7 @@ jobs:
23
23
  run: |
24
24
  chmod +x scripts/setup-submodules-sparse.sh
25
25
  ./scripts/setup-submodules-sparse.sh
26
- - uses: actions/setup-node@v3
26
+ - uses: actions/setup-node@v6
27
27
  with:
28
28
  node-version: lts/*
29
29
  check-latest: true
@@ -43,9 +43,9 @@ jobs:
43
43
  echo "Updated server.json version to $VERSION"
44
44
 
45
45
  - name: Setup Go
46
- uses: actions/setup-go@v5
46
+ uses: actions/setup-go@v6
47
47
  with:
48
- go-version: '1.21'
48
+ go-version: '1.25'
49
49
 
50
50
  - name: Build MCP Publisher from source
51
51
  run: |
package/CHANGELOG.md CHANGED
@@ -1,3 +1,15 @@
1
+ ## [1.6.1](https://github.com/appium/appium-mcp/compare/v1.6.0...v1.6.1) (2026-01-05)
2
+
3
+ ### Bug Fixes
4
+
5
+ * Update Go version in publish workflow ([#74](https://github.com/appium/appium-mcp/issues/74)) ([580be03](https://github.com/appium/appium-mcp/commit/580be03b59aa4ce8b47f169cfbeb8ac8dd310f35))
6
+
7
+ ## [1.6.0](https://github.com/appium/appium-mcp/compare/v1.5.0...v1.6.0) (2025-12-22)
8
+
9
+ ### Features
10
+
11
+ * **screenshot:** add configurable screenshot directory via SCREENSHOTS_DIR env var ([#54](https://github.com/appium/appium-mcp/issues/54)) ([f1957ad](https://github.com/appium/appium-mcp/commit/f1957ad0372d999db67f77b7b85a035546486b86))
12
+
1
13
  ## [1.5.0](https://github.com/appium/appium-mcp/compare/v1.4.0...v1.5.0) (2025-12-19)
2
14
 
3
15
  ### Features
package/README.md CHANGED
@@ -157,6 +157,10 @@ Create a `capabilities.json` file to define your device capabilities:
157
157
 
158
158
  Set the `CAPABILITIES_CONFIG` environment variable to point to your configuration file.
159
159
 
160
+ ### Screenshots
161
+
162
+ Set the `SCREENSHOTS_DIR` environment variable to specify where screenshots are saved. If not set, screenshots are saved to the current working directory. Supports both absolute and relative paths (relative paths are resolved from the current working directory). The directory is created automatically if it doesn't exist.
163
+
160
164
  ## 🎯 Available Tools
161
165
 
162
166
  MCP Appium provides a comprehensive set of tools organized into the following categories:
@@ -0,0 +1 @@
1
+ export {};
@@ -0,0 +1,212 @@
1
+ import { describe, test, expect, beforeEach, afterEach, jest, } from '@jest/globals';
2
+ import { join, isAbsolute } from 'path';
3
+ /**
4
+ * Local implementation of resolveScreenshotDir for testing.
5
+ * This mirrors the implementation in screenshot.ts to avoid importing
6
+ * the module which has heavy dependencies.
7
+ */
8
+ function resolveScreenshotDir() {
9
+ const screenshotDir = process.env.SCREENSHOTS_DIR;
10
+ if (!screenshotDir) {
11
+ return process.cwd();
12
+ }
13
+ if (isAbsolute(screenshotDir)) {
14
+ return screenshotDir;
15
+ }
16
+ return join(process.cwd(), screenshotDir);
17
+ }
18
+ /**
19
+ * Local implementation of executeScreenshot for testing.
20
+ * This mirrors the implementation in screenshot.ts.
21
+ */
22
+ async function executeScreenshot(deps) {
23
+ const driver = deps.getDriver();
24
+ if (!driver) {
25
+ throw new Error('No driver found');
26
+ }
27
+ try {
28
+ const screenshotBase64 = await driver.getScreenshot();
29
+ const screenshotBuffer = Buffer.from(screenshotBase64, 'base64');
30
+ const timestamp = deps.dateNow();
31
+ const filename = `screenshot_${timestamp}.png`;
32
+ const screenshotDir = deps.resolveScreenshotDir();
33
+ await deps.mkdir(screenshotDir, { recursive: true });
34
+ const filepath = join(screenshotDir, filename);
35
+ await deps.writeFile(filepath, screenshotBuffer);
36
+ return {
37
+ content: [
38
+ {
39
+ type: 'text',
40
+ text: `Screenshot saved successfully to: ${filename}`,
41
+ },
42
+ ],
43
+ };
44
+ }
45
+ catch (err) {
46
+ return {
47
+ content: [
48
+ {
49
+ type: 'text',
50
+ text: `Failed to take screenshot. err: ${err.toString()}`,
51
+ },
52
+ ],
53
+ };
54
+ }
55
+ }
56
+ describe('resolveScreenshotDir', () => {
57
+ const originalEnv = process.env.SCREENSHOTS_DIR;
58
+ const cwd = process.cwd();
59
+ beforeEach(() => {
60
+ delete process.env.SCREENSHOTS_DIR;
61
+ });
62
+ afterEach(() => {
63
+ if (originalEnv !== undefined) {
64
+ process.env.SCREENSHOTS_DIR = originalEnv;
65
+ }
66
+ else {
67
+ delete process.env.SCREENSHOTS_DIR;
68
+ }
69
+ });
70
+ test('should return process.cwd() when SCREENSHOTS_DIR is not set', () => {
71
+ const result = resolveScreenshotDir();
72
+ expect(result).toBe(cwd);
73
+ });
74
+ test('should return process.cwd() when SCREENSHOTS_DIR is empty string', () => {
75
+ process.env.SCREENSHOTS_DIR = '';
76
+ const result = resolveScreenshotDir();
77
+ expect(result).toBe(cwd);
78
+ });
79
+ test('should return absolute path as-is when SCREENSHOTS_DIR is absolute', () => {
80
+ const absolutePath = '/tmp/screenshots';
81
+ process.env.SCREENSHOTS_DIR = absolutePath;
82
+ const result = resolveScreenshotDir();
83
+ expect(result).toBe(absolutePath);
84
+ });
85
+ test('should join relative path with process.cwd()', () => {
86
+ const relativePath = 'screenshots';
87
+ process.env.SCREENSHOTS_DIR = relativePath;
88
+ const result = resolveScreenshotDir();
89
+ expect(result).toBe(join(cwd, relativePath));
90
+ });
91
+ test('should handle nested relative paths', () => {
92
+ const relativePath = 'output/screenshots/test';
93
+ process.env.SCREENSHOTS_DIR = relativePath;
94
+ const result = resolveScreenshotDir();
95
+ expect(result).toBe(join(cwd, relativePath));
96
+ });
97
+ test('should handle relative path starting with ./', () => {
98
+ const relativePath = './screenshots';
99
+ process.env.SCREENSHOTS_DIR = relativePath;
100
+ const result = resolveScreenshotDir();
101
+ expect(result).toBe(join(cwd, relativePath));
102
+ });
103
+ test('should handle relative path with parent directory reference', () => {
104
+ const relativePath = '../screenshots';
105
+ process.env.SCREENSHOTS_DIR = relativePath;
106
+ const result = resolveScreenshotDir();
107
+ expect(result).toBe(join(cwd, relativePath));
108
+ });
109
+ });
110
+ describe('executeScreenshot', () => {
111
+ const mockBase64 = 'dGVzdA=='; // 'test' in base64
112
+ const mockTimestamp = 1234567890;
113
+ function createMockDeps(overrides = {}) {
114
+ return {
115
+ getDriver: jest.fn(() => ({
116
+ getScreenshot: jest.fn(() => Promise.resolve(mockBase64)),
117
+ })),
118
+ writeFile: jest.fn(() => Promise.resolve()),
119
+ mkdir: jest.fn(() => Promise.resolve()),
120
+ resolveScreenshotDir: jest.fn(() => '/mock/screenshots'),
121
+ dateNow: jest.fn(() => mockTimestamp),
122
+ ...overrides,
123
+ };
124
+ }
125
+ test('should throw error when no driver found', async () => {
126
+ const deps = createMockDeps({
127
+ getDriver: jest.fn(() => null),
128
+ });
129
+ await expect(executeScreenshot(deps)).rejects.toThrow('No driver found');
130
+ });
131
+ test('should return success content with filename', async () => {
132
+ const deps = createMockDeps();
133
+ const result = await executeScreenshot(deps);
134
+ expect(result).toEqual({
135
+ content: [
136
+ {
137
+ type: 'text',
138
+ text: `Screenshot saved successfully to: screenshot_${mockTimestamp}.png`,
139
+ },
140
+ ],
141
+ });
142
+ });
143
+ test('should use resolved screenshot directory from SCREENSHOTS_DIR', async () => {
144
+ const customDir = '/custom/path/screenshots';
145
+ const deps = createMockDeps({
146
+ resolveScreenshotDir: jest.fn(() => customDir),
147
+ });
148
+ await executeScreenshot(deps);
149
+ expect(deps.mkdir).toHaveBeenCalledWith(customDir, { recursive: true });
150
+ expect(deps.writeFile).toHaveBeenCalledWith(join(customDir, `screenshot_${mockTimestamp}.png`), expect.any(Buffer));
151
+ });
152
+ test('should create directory with recursive option', async () => {
153
+ const deps = createMockDeps();
154
+ await executeScreenshot(deps);
155
+ expect(deps.mkdir).toHaveBeenCalledWith('/mock/screenshots', {
156
+ recursive: true,
157
+ });
158
+ });
159
+ test('should write screenshot buffer to correct filepath', async () => {
160
+ const deps = createMockDeps();
161
+ await executeScreenshot(deps);
162
+ expect(deps.writeFile).toHaveBeenCalledWith(`/mock/screenshots/screenshot_${mockTimestamp}.png`, Buffer.from(mockBase64, 'base64'));
163
+ });
164
+ test('should return error content when screenshot fails', async () => {
165
+ const errorMessage = 'Screenshot capture failed';
166
+ const deps = createMockDeps({
167
+ getDriver: jest.fn(() => ({
168
+ getScreenshot: jest.fn(() => Promise.reject(new Error(errorMessage))),
169
+ })),
170
+ });
171
+ const result = await executeScreenshot(deps);
172
+ expect(result).toEqual({
173
+ content: [
174
+ {
175
+ type: 'text',
176
+ text: `Failed to take screenshot. err: Error: ${errorMessage}`,
177
+ },
178
+ ],
179
+ });
180
+ });
181
+ test('should return error content when mkdir fails', async () => {
182
+ const errorMessage = 'Permission denied';
183
+ const deps = createMockDeps({
184
+ mkdir: jest.fn(() => Promise.reject(new Error(errorMessage))),
185
+ });
186
+ const result = await executeScreenshot(deps);
187
+ expect(result).toEqual({
188
+ content: [
189
+ {
190
+ type: 'text',
191
+ text: `Failed to take screenshot. err: Error: ${errorMessage}`,
192
+ },
193
+ ],
194
+ });
195
+ });
196
+ test('should return error content when writeFile fails', async () => {
197
+ const errorMessage = 'Disk full';
198
+ const deps = createMockDeps({
199
+ writeFile: jest.fn(() => Promise.reject(new Error(errorMessage))),
200
+ });
201
+ const result = await executeScreenshot(deps);
202
+ expect(result).toEqual({
203
+ content: [
204
+ {
205
+ type: 'text',
206
+ text: `Failed to take screenshot. err: Error: ${errorMessage}`,
207
+ },
208
+ ],
209
+ });
210
+ });
211
+ });
212
+ //# sourceMappingURL=screenshot.test.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"screenshot.test.js","sourceRoot":"","sources":["../../src/tests/screenshot.test.ts"],"names":[],"mappings":"AAAA,OAAO,EACL,QAAQ,EACR,IAAI,EACJ,MAAM,EACN,UAAU,EACV,SAAS,EACT,IAAI,GACL,MAAM,eAAe,CAAC;AACvB,OAAO,EAAE,IAAI,EAAE,UAAU,EAAE,MAAM,MAAM,CAAC;AAExC;;;;GAIG;AACH,SAAS,oBAAoB;IAC3B,MAAM,aAAa,GAAG,OAAO,CAAC,GAAG,CAAC,eAAe,CAAC;IAElD,IAAI,CAAC,aAAa,EAAE,CAAC;QACnB,OAAO,OAAO,CAAC,GAAG,EAAE,CAAC;IACvB,CAAC;IAED,IAAI,UAAU,CAAC,aAAa,CAAC,EAAE,CAAC;QAC9B,OAAO,aAAa,CAAC;IACvB,CAAC;IAED,OAAO,IAAI,CAAC,OAAO,CAAC,GAAG,EAAE,EAAE,aAAa,CAAC,CAAC;AAC5C,CAAC;AAaD;;;GAGG;AACH,KAAK,UAAU,iBAAiB,CAAC,IAAoB;IACnD,MAAM,MAAM,GAAG,IAAI,CAAC,SAAS,EAAE,CAAC;IAChC,IAAI,CAAC,MAAM,EAAE,CAAC;QACZ,MAAM,IAAI,KAAK,CAAC,iBAAiB,CAAC,CAAC;IACrC,CAAC;IAED,IAAI,CAAC;QACH,MAAM,gBAAgB,GAAG,MAAM,MAAM,CAAC,aAAa,EAAE,CAAC;QACtD,MAAM,gBAAgB,GAAG,MAAM,CAAC,IAAI,CAAC,gBAAgB,EAAE,QAAQ,CAAC,CAAC;QACjE,MAAM,SAAS,GAAG,IAAI,CAAC,OAAO,EAAE,CAAC;QACjC,MAAM,QAAQ,GAAG,cAAc,SAAS,MAAM,CAAC;QAC/C,MAAM,aAAa,GAAG,IAAI,CAAC,oBAAoB,EAAE,CAAC;QAElD,MAAM,IAAI,CAAC,KAAK,CAAC,aAAa,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;QACrD,MAAM,QAAQ,GAAG,IAAI,CAAC,aAAa,EAAE,QAAQ,CAAC,CAAC;QAC/C,MAAM,IAAI,CAAC,SAAS,CAAC,QAAQ,EAAE,gBAAgB,CAAC,CAAC;QAEjD,OAAO;YACL,OAAO,EAAE;gBACP;oBACE,IAAI,EAAE,MAAM;oBACZ,IAAI,EAAE,qCAAqC,QAAQ,EAAE;iBACtD;aACF;SACF,CAAC;IACJ,CAAC;IAAC,OAAO,GAAQ,EAAE,CAAC;QAClB,OAAO;YACL,OAAO,EAAE;gBACP;oBACE,IAAI,EAAE,MAAM;oBACZ,IAAI,EAAE,mCAAmC,GAAG,CAAC,QAAQ,EAAE,EAAE;iBAC1D;aACF;SACF,CAAC;IACJ,CAAC;AACH,CAAC;AAED,QAAQ,CAAC,sBAAsB,EAAE,GAAG,EAAE;IACpC,MAAM,WAAW,GAAG,OAAO,CAAC,GAAG,CAAC,eAAe,CAAC;IAChD,MAAM,GAAG,GAAG,OAAO,CAAC,GAAG,EAAE,CAAC;IAE1B,UAAU,CAAC,GAAG,EAAE;QACd,OAAO,OAAO,CAAC,GAAG,CAAC,eAAe,CAAC;IACrC,CAAC,CAAC,CAAC;IAEH,SAAS,CAAC,GAAG,EAAE;QACb,IAAI,WAAW,KAAK,SAAS,EAAE,CAAC;YAC9B,OAAO,CAAC,GAAG,CAAC,eAAe,GAAG,WAAW,CAAC;QAC5C,CAAC;aAAM,CAAC;YACN,OAAO,OAAO,CAAC,GAAG,CAAC,eAAe,CAAC;QACrC,CAAC;IACH,CAAC,CAAC,CAAC;IAEH,IAAI,CAAC,6DAA6D,EAAE,GAAG,EAAE;QACvE,MAAM,MAAM,GAAG,oBAAoB,EAAE,CAAC;QACtC,MAAM,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IAC3B,CAAC,CAAC,CAAC;IAEH,IAAI,CAAC,kEAAkE,EAAE,GAAG,EAAE;QAC5E,OAAO,CAAC,GAAG,CAAC,eAAe,GAAG,EAAE,CAAC;QACjC,MAAM,MAAM,GAAG,oBAAoB,EAAE,CAAC;QACtC,MAAM,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,GAAG,CAAC,CAAC;IAC3B,CAAC,CAAC,CAAC;IAEH,IAAI,CAAC,oEAAoE,EAAE,GAAG,EAAE;QAC9E,MAAM,YAAY,GAAG,kBAAkB,CAAC;QACxC,OAAO,CAAC,GAAG,CAAC,eAAe,GAAG,YAAY,CAAC;QAC3C,MAAM,MAAM,GAAG,oBAAoB,EAAE,CAAC;QACtC,MAAM,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,YAAY,CAAC,CAAC;IACpC,CAAC,CAAC,CAAC;IAEH,IAAI,CAAC,8CAA8C,EAAE,GAAG,EAAE;QACxD,MAAM,YAAY,GAAG,aAAa,CAAC;QACnC,OAAO,CAAC,GAAG,CAAC,eAAe,GAAG,YAAY,CAAC;QAC3C,MAAM,MAAM,GAAG,oBAAoB,EAAE,CAAC;QACtC,MAAM,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,YAAY,CAAC,CAAC,CAAC;IAC/C,CAAC,CAAC,CAAC;IAEH,IAAI,CAAC,qCAAqC,EAAE,GAAG,EAAE;QAC/C,MAAM,YAAY,GAAG,yBAAyB,CAAC;QAC/C,OAAO,CAAC,GAAG,CAAC,eAAe,GAAG,YAAY,CAAC;QAC3C,MAAM,MAAM,GAAG,oBAAoB,EAAE,CAAC;QACtC,MAAM,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,YAAY,CAAC,CAAC,CAAC;IAC/C,CAAC,CAAC,CAAC;IAEH,IAAI,CAAC,8CAA8C,EAAE,GAAG,EAAE;QACxD,MAAM,YAAY,GAAG,eAAe,CAAC;QACrC,OAAO,CAAC,GAAG,CAAC,eAAe,GAAG,YAAY,CAAC;QAC3C,MAAM,MAAM,GAAG,oBAAoB,EAAE,CAAC;QACtC,MAAM,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,YAAY,CAAC,CAAC,CAAC;IAC/C,CAAC,CAAC,CAAC;IAEH,IAAI,CAAC,6DAA6D,EAAE,GAAG,EAAE;QACvE,MAAM,YAAY,GAAG,gBAAgB,CAAC;QACtC,OAAO,CAAC,GAAG,CAAC,eAAe,GAAG,YAAY,CAAC;QAC3C,MAAM,MAAM,GAAG,oBAAoB,EAAE,CAAC;QACtC,MAAM,CAAC,MAAM,CAAC,CAAC,IAAI,CAAC,IAAI,CAAC,GAAG,EAAE,YAAY,CAAC,CAAC,CAAC;IAC/C,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC;AAEH,QAAQ,CAAC,mBAAmB,EAAE,GAAG,EAAE;IACjC,MAAM,UAAU,GAAG,UAAU,CAAC,CAAC,mBAAmB;IAClD,MAAM,aAAa,GAAG,UAAU,CAAC;IAEjC,SAAS,cAAc,CACrB,YAAqC,EAAE;QAEvC,OAAO;YACL,SAAS,EAAE,IAAI,CAAC,EAAE,CAAC,GAAG,EAAE,CAAC,CAAC;gBACxB,aAAa,EAAE,IAAI,CAAC,EAAE,CAAC,GAAG,EAAE,CAAC,OAAO,CAAC,OAAO,CAAC,UAAU,CAAC,CAAC;aAC1D,CAAC,CAAQ;YACV,SAAS,EAAE,IAAI,CAAC,EAAE,CAAC,GAAG,EAAE,CAAC,OAAO,CAAC,OAAO,EAAE,CAAQ;YAClD,KAAK,EAAE,IAAI,CAAC,EAAE,CAAC,GAAG,EAAE,CAAC,OAAO,CAAC,OAAO,EAAE,CAAQ;YAC9C,oBAAoB,EAAE,IAAI,CAAC,EAAE,CAAC,GAAG,EAAE,CAAC,mBAAmB,CAAQ;YAC/D,OAAO,EAAE,IAAI,CAAC,EAAE,CAAC,GAAG,EAAE,CAAC,aAAa,CAAQ;YAC5C,GAAG,SAAS;SACb,CAAC;IACJ,CAAC;IAED,IAAI,CAAC,yCAAyC,EAAE,KAAK,IAAI,EAAE;QACzD,MAAM,IAAI,GAAG,cAAc,CAAC;YAC1B,SAAS,EAAE,IAAI,CAAC,EAAE,CAAC,GAAG,EAAE,CAAC,IAAI,CAAQ;SACtC,CAAC,CAAC;QAEH,MAAM,MAAM,CAAC,iBAAiB,CAAC,IAAI,CAAC,CAAC,CAAC,OAAO,CAAC,OAAO,CAAC,iBAAiB,CAAC,CAAC;IAC3E,CAAC,CAAC,CAAC;IAEH,IAAI,CAAC,6CAA6C,EAAE,KAAK,IAAI,EAAE;QAC7D,MAAM,IAAI,GAAG,cAAc,EAAE,CAAC;QAE9B,MAAM,MAAM,GAAG,MAAM,iBAAiB,CAAC,IAAI,CAAC,CAAC;QAE7C,MAAM,CAAC,MAAM,CAAC,CAAC,OAAO,CAAC;YACrB,OAAO,EAAE;gBACP;oBACE,IAAI,EAAE,MAAM;oBACZ,IAAI,EAAE,gDAAgD,aAAa,MAAM;iBAC1E;aACF;SACF,CAAC,CAAC;IACL,CAAC,CAAC,CAAC;IAEH,IAAI,CAAC,+DAA+D,EAAE,KAAK,IAAI,EAAE;QAC/E,MAAM,SAAS,GAAG,0BAA0B,CAAC;QAC7C,MAAM,IAAI,GAAG,cAAc,CAAC;YAC1B,oBAAoB,EAAE,IAAI,CAAC,EAAE,CAAC,GAAG,EAAE,CAAC,SAAS,CAAQ;SACtD,CAAC,CAAC;QAEH,MAAM,iBAAiB,CAAC,IAAI,CAAC,CAAC;QAE9B,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,oBAAoB,CAAC,SAAS,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;QACxE,MAAM,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC,oBAAoB,CACzC,IAAI,CAAC,SAAS,EAAE,cAAc,aAAa,MAAM,CAAC,EAClD,MAAM,CAAC,GAAG,CAAC,MAAM,CAAC,CACnB,CAAC;IACJ,CAAC,CAAC,CAAC;IAEH,IAAI,CAAC,+CAA+C,EAAE,KAAK,IAAI,EAAE;QAC/D,MAAM,IAAI,GAAG,cAAc,EAAE,CAAC;QAE9B,MAAM,iBAAiB,CAAC,IAAI,CAAC,CAAC;QAE9B,MAAM,CAAC,IAAI,CAAC,KAAK,CAAC,CAAC,oBAAoB,CAAC,mBAAmB,EAAE;YAC3D,SAAS,EAAE,IAAI;SAChB,CAAC,CAAC;IACL,CAAC,CAAC,CAAC;IAEH,IAAI,CAAC,oDAAoD,EAAE,KAAK,IAAI,EAAE;QACpE,MAAM,IAAI,GAAG,cAAc,EAAE,CAAC;QAE9B,MAAM,iBAAiB,CAAC,IAAI,CAAC,CAAC;QAE9B,MAAM,CAAC,IAAI,CAAC,SAAS,CAAC,CAAC,oBAAoB,CACzC,gCAAgC,aAAa,MAAM,EACnD,MAAM,CAAC,IAAI,CAAC,UAAU,EAAE,QAAQ,CAAC,CAClC,CAAC;IACJ,CAAC,CAAC,CAAC;IAEH,IAAI,CAAC,mDAAmD,EAAE,KAAK,IAAI,EAAE;QACnE,MAAM,YAAY,GAAG,2BAA2B,CAAC;QACjD,MAAM,IAAI,GAAG,cAAc,CAAC;YAC1B,SAAS,EAAE,IAAI,CAAC,EAAE,CAAC,GAAG,EAAE,CAAC,CAAC;gBACxB,aAAa,EAAE,IAAI,CAAC,EAAE,CAAC,GAAG,EAAE,CAAC,OAAO,CAAC,MAAM,CAAC,IAAI,KAAK,CAAC,YAAY,CAAC,CAAC,CAAC;aACtE,CAAC,CAAQ;SACX,CAAC,CAAC;QAEH,MAAM,MAAM,GAAG,MAAM,iBAAiB,CAAC,IAAI,CAAC,CAAC;QAE7C,MAAM,CAAC,MAAM,CAAC,CAAC,OAAO,CAAC;YACrB,OAAO,EAAE;gBACP;oBACE,IAAI,EAAE,MAAM;oBACZ,IAAI,EAAE,0CAA0C,YAAY,EAAE;iBAC/D;aACF;SACF,CAAC,CAAC;IACL,CAAC,CAAC,CAAC;IAEH,IAAI,CAAC,8CAA8C,EAAE,KAAK,IAAI,EAAE;QAC9D,MAAM,YAAY,GAAG,mBAAmB,CAAC;QACzC,MAAM,IAAI,GAAG,cAAc,CAAC;YAC1B,KAAK,EAAE,IAAI,CAAC,EAAE,CAAC,GAAG,EAAE,CAAC,OAAO,CAAC,MAAM,CAAC,IAAI,KAAK,CAAC,YAAY,CAAC,CAAC,CAAQ;SACrE,CAAC,CAAC;QAEH,MAAM,MAAM,GAAG,MAAM,iBAAiB,CAAC,IAAI,CAAC,CAAC;QAE7C,MAAM,CAAC,MAAM,CAAC,CAAC,OAAO,CAAC;YACrB,OAAO,EAAE;gBACP;oBACE,IAAI,EAAE,MAAM;oBACZ,IAAI,EAAE,0CAA0C,YAAY,EAAE;iBAC/D;aACF;SACF,CAAC,CAAC;IACL,CAAC,CAAC,CAAC;IAEH,IAAI,CAAC,kDAAkD,EAAE,KAAK,IAAI,EAAE;QAClE,MAAM,YAAY,GAAG,WAAW,CAAC;QACjC,MAAM,IAAI,GAAG,cAAc,CAAC;YAC1B,SAAS,EAAE,IAAI,CAAC,EAAE,CAAC,GAAG,EAAE,CAAC,OAAO,CAAC,MAAM,CAAC,IAAI,KAAK,CAAC,YAAY,CAAC,CAAC,CAAQ;SACzE,CAAC,CAAC;QAEH,MAAM,MAAM,GAAG,MAAM,iBAAiB,CAAC,IAAI,CAAC,CAAC;QAE7C,MAAM,CAAC,MAAM,CAAC,CAAC,OAAO,CAAC;YACrB,OAAO,EAAE;gBACP;oBACE,IAAI,EAAE,MAAM;oBACZ,IAAI,EAAE,0CAA0C,YAAY,EAAE;iBAC/D;aACF;SACF,CAAC,CAAC;IACL,CAAC,CAAC,CAAC;AACL,CAAC,CAAC,CAAC"}
@@ -1,2 +1,20 @@
1
1
  import { FastMCP } from 'fastmcp/dist/FastMCP.js';
2
+ import { writeFile, mkdir } from 'fs/promises';
3
+ /**
4
+ * Resolves the screenshot directory path.
5
+ * - If SCREENSHOTS_DIR is not set, returns process.cwd()
6
+ * - If SCREENSHOTS_DIR is absolute, returns it as-is
7
+ * - If SCREENSHOTS_DIR is relative, joins it with process.cwd()
8
+ */
9
+ export declare function resolveScreenshotDir(): string;
10
+ export interface ScreenshotDeps {
11
+ getDriver: () => {
12
+ getScreenshot: () => Promise<string>;
13
+ } | null;
14
+ writeFile: typeof writeFile;
15
+ mkdir: typeof mkdir;
16
+ resolveScreenshotDir: typeof resolveScreenshotDir;
17
+ dateNow: () => number;
18
+ }
19
+ export declare function executeScreenshot(deps?: ScreenshotDeps): Promise<any>;
2
20
  export default function screenshot(server: FastMCP): void;
@@ -1,6 +1,67 @@
1
1
  import { getDriver } from '../../session-store.js';
2
- import { writeFile } from 'fs/promises';
3
- import { join } from 'path';
2
+ import { writeFile, mkdir } from 'fs/promises';
3
+ import { join, isAbsolute } from 'path';
4
+ /**
5
+ * Resolves the screenshot directory path.
6
+ * - If SCREENSHOTS_DIR is not set, returns process.cwd()
7
+ * - If SCREENSHOTS_DIR is absolute, returns it as-is
8
+ * - If SCREENSHOTS_DIR is relative, joins it with process.cwd()
9
+ */
10
+ export function resolveScreenshotDir() {
11
+ const screenshotDir = process.env.SCREENSHOTS_DIR;
12
+ if (!screenshotDir) {
13
+ return process.cwd();
14
+ }
15
+ if (isAbsolute(screenshotDir)) {
16
+ return screenshotDir;
17
+ }
18
+ return join(process.cwd(), screenshotDir);
19
+ }
20
+ const defaultDeps = {
21
+ getDriver,
22
+ writeFile,
23
+ mkdir,
24
+ resolveScreenshotDir,
25
+ dateNow: () => Date.now(),
26
+ };
27
+ export async function executeScreenshot(deps = defaultDeps) {
28
+ const driver = deps.getDriver();
29
+ if (!driver) {
30
+ throw new Error('No driver found');
31
+ }
32
+ try {
33
+ const screenshotBase64 = await driver.getScreenshot();
34
+ // Convert base64 to buffer
35
+ const screenshotBuffer = Buffer.from(screenshotBase64, 'base64');
36
+ // Generate filename with timestamp
37
+ const timestamp = deps.dateNow();
38
+ const filename = `screenshot_${timestamp}.png`;
39
+ const screenshotDir = deps.resolveScreenshotDir();
40
+ // Create a directory if it doesn't exist
41
+ await deps.mkdir(screenshotDir, { recursive: true });
42
+ const filepath = join(screenshotDir, filename);
43
+ // Save screenshot to disk
44
+ await deps.writeFile(filepath, screenshotBuffer);
45
+ return {
46
+ content: [
47
+ {
48
+ type: 'text',
49
+ text: `Screenshot saved successfully to: ${filepath}`,
50
+ },
51
+ ],
52
+ };
53
+ }
54
+ catch (err) {
55
+ return {
56
+ content: [
57
+ {
58
+ type: 'text',
59
+ text: `Failed to take screenshot. err: ${err.toString()}`,
60
+ },
61
+ ],
62
+ };
63
+ }
64
+ }
4
65
  export default function screenshot(server) {
5
66
  server.addTool({
6
67
  name: 'appium_screenshot',
@@ -9,41 +70,7 @@ export default function screenshot(server) {
9
70
  readOnlyHint: false,
10
71
  openWorldHint: false,
11
72
  },
12
- execute: async (args, context) => {
13
- const driver = getDriver();
14
- if (!driver) {
15
- throw new Error('No driver found');
16
- }
17
- try {
18
- const screenshotBase64 = await driver.getScreenshot();
19
- // Convert base64 to buffer
20
- const screenshotBuffer = Buffer.from(screenshotBase64, 'base64');
21
- // Generate filename with timestamp
22
- const timestamp = Date.now();
23
- const filename = `screenshot_${timestamp}.png`;
24
- const filepath = join(process.cwd(), filename);
25
- // Save screenshot to disk
26
- await writeFile(filepath, screenshotBuffer);
27
- return {
28
- content: [
29
- {
30
- type: 'text',
31
- text: `Screenshot saved successfully to: ${filename}`,
32
- },
33
- ],
34
- };
35
- }
36
- catch (err) {
37
- return {
38
- content: [
39
- {
40
- type: 'text',
41
- text: `Failed to take screenshot. err: ${err.toString()}`,
42
- },
43
- ],
44
- };
45
- }
46
- },
73
+ execute: async () => executeScreenshot(),
47
74
  });
48
75
  }
49
76
  //# sourceMappingURL=screenshot.js.map
@@ -1 +1 @@
1
- {"version":3,"file":"screenshot.js","sourceRoot":"","sources":["../../../src/tools/interactions/screenshot.ts"],"names":[],"mappings":"AAEA,OAAO,EAAE,SAAS,EAAE,MAAM,wBAAwB,CAAC;AACnD,OAAO,EAAE,SAAS,EAAE,MAAM,aAAa,CAAC;AACxC,OAAO,EAAE,IAAI,EAAE,MAAM,MAAM,CAAC;AAE5B,MAAM,CAAC,OAAO,UAAU,UAAU,CAAC,MAAe;IAChD,MAAM,CAAC,OAAO,CAAC;QACb,IAAI,EAAE,mBAAmB;QACzB,WAAW,EACT,iEAAiE;QACnE,WAAW,EAAE;YACX,YAAY,EAAE,KAAK;YACnB,aAAa,EAAE,KAAK;SACrB;QACD,OAAO,EAAE,KAAK,EAAE,IAAS,EAAE,OAAY,EAAgB,EAAE;YACvD,MAAM,MAAM,GAAG,SAAS,EAAE,CAAC;YAC3B,IAAI,CAAC,MAAM,EAAE,CAAC;gBACZ,MAAM,IAAI,KAAK,CAAC,iBAAiB,CAAC,CAAC;YACrC,CAAC;YAED,IAAI,CAAC;gBACH,MAAM,gBAAgB,GAAG,MAAM,MAAM,CAAC,aAAa,EAAE,CAAC;gBAEtD,2BAA2B;gBAC3B,MAAM,gBAAgB,GAAG,MAAM,CAAC,IAAI,CAAC,gBAAgB,EAAE,QAAQ,CAAC,CAAC;gBAEjE,mCAAmC;gBACnC,MAAM,SAAS,GAAG,IAAI,CAAC,GAAG,EAAE,CAAC;gBAC7B,MAAM,QAAQ,GAAG,cAAc,SAAS,MAAM,CAAC;gBAC/C,MAAM,QAAQ,GAAG,IAAI,CAAC,OAAO,CAAC,GAAG,EAAE,EAAE,QAAQ,CAAC,CAAC;gBAE/C,0BAA0B;gBAC1B,MAAM,SAAS,CAAC,QAAQ,EAAE,gBAAgB,CAAC,CAAC;gBAE5C,OAAO;oBACL,OAAO,EAAE;wBACP;4BACE,IAAI,EAAE,MAAM;4BACZ,IAAI,EAAE,qCAAqC,QAAQ,EAAE;yBACtD;qBACF;iBACF,CAAC;YACJ,CAAC;YAAC,OAAO,GAAQ,EAAE,CAAC;gBAClB,OAAO;oBACL,OAAO,EAAE;wBACP;4BACE,IAAI,EAAE,MAAM;4BACZ,IAAI,EAAE,mCAAmC,GAAG,CAAC,QAAQ,EAAE,EAAE;yBAC1D;qBACF;iBACF,CAAC;YACJ,CAAC;QACH,CAAC;KACF,CAAC,CAAC;AACL,CAAC"}
1
+ {"version":3,"file":"screenshot.js","sourceRoot":"","sources":["../../../src/tools/interactions/screenshot.ts"],"names":[],"mappings":"AACA,OAAO,EAAE,SAAS,EAAE,MAAM,wBAAwB,CAAC;AACnD,OAAO,EAAE,SAAS,EAAE,KAAK,EAAE,MAAM,aAAa,CAAC;AAC/C,OAAO,EAAE,IAAI,EAAE,UAAU,EAAE,MAAM,MAAM,CAAC;AAExC;;;;;GAKG;AACH,MAAM,UAAU,oBAAoB;IAClC,MAAM,aAAa,GAAG,OAAO,CAAC,GAAG,CAAC,eAAe,CAAC;IAElD,IAAI,CAAC,aAAa,EAAE,CAAC;QACnB,OAAO,OAAO,CAAC,GAAG,EAAE,CAAC;IACvB,CAAC;IAED,IAAI,UAAU,CAAC,aAAa,CAAC,EAAE,CAAC;QAC9B,OAAO,aAAa,CAAC;IACvB,CAAC;IAED,OAAO,IAAI,CAAC,OAAO,CAAC,GAAG,EAAE,EAAE,aAAa,CAAC,CAAC;AAC5C,CAAC;AAUD,MAAM,WAAW,GAAmB;IAClC,SAAS;IACT,SAAS;IACT,KAAK;IACL,oBAAoB;IACpB,OAAO,EAAE,GAAG,EAAE,CAAC,IAAI,CAAC,GAAG,EAAE;CAC1B,CAAC;AAEF,MAAM,CAAC,KAAK,UAAU,iBAAiB,CACrC,OAAuB,WAAW;IAElC,MAAM,MAAM,GAAG,IAAI,CAAC,SAAS,EAAE,CAAC;IAChC,IAAI,CAAC,MAAM,EAAE,CAAC;QACZ,MAAM,IAAI,KAAK,CAAC,iBAAiB,CAAC,CAAC;IACrC,CAAC;IAED,IAAI,CAAC;QACH,MAAM,gBAAgB,GAAG,MAAM,MAAM,CAAC,aAAa,EAAE,CAAC;QAEtD,2BAA2B;QAC3B,MAAM,gBAAgB,GAAG,MAAM,CAAC,IAAI,CAAC,gBAAgB,EAAE,QAAQ,CAAC,CAAC;QAEjE,mCAAmC;QACnC,MAAM,SAAS,GAAG,IAAI,CAAC,OAAO,EAAE,CAAC;QACjC,MAAM,QAAQ,GAAG,cAAc,SAAS,MAAM,CAAC;QAC/C,MAAM,aAAa,GAAG,IAAI,CAAC,oBAAoB,EAAE,CAAC;QAElD,yCAAyC;QACzC,MAAM,IAAI,CAAC,KAAK,CAAC,aAAa,EAAE,EAAE,SAAS,EAAE,IAAI,EAAE,CAAC,CAAC;QAErD,MAAM,QAAQ,GAAG,IAAI,CAAC,aAAa,EAAE,QAAQ,CAAC,CAAC;QAE/C,0BAA0B;QAC1B,MAAM,IAAI,CAAC,SAAS,CAAC,QAAQ,EAAE,gBAAgB,CAAC,CAAC;QAEjD,OAAO;YACL,OAAO,EAAE;gBACP;oBACE,IAAI,EAAE,MAAM;oBACZ,IAAI,EAAE,qCAAqC,QAAQ,EAAE;iBACtD;aACF;SACF,CAAC;IACJ,CAAC;IAAC,OAAO,GAAQ,EAAE,CAAC;QAClB,OAAO;YACL,OAAO,EAAE;gBACP;oBACE,IAAI,EAAE,MAAM;oBACZ,IAAI,EAAE,mCAAmC,GAAG,CAAC,QAAQ,EAAE,EAAE;iBAC1D;aACF;SACF,CAAC;IACJ,CAAC;AACH,CAAC;AAED,MAAM,CAAC,OAAO,UAAU,UAAU,CAAC,MAAe;IAChD,MAAM,CAAC,OAAO,CAAC;QACb,IAAI,EAAE,mBAAmB;QACzB,WAAW,EACT,iEAAiE;QACnE,WAAW,EAAE;YACX,YAAY,EAAE,KAAK;YACnB,aAAa,EAAE,KAAK;SACrB;QACD,OAAO,EAAE,KAAK,IAAkB,EAAE,CAAC,iBAAiB,EAAE;KACvD,CAAC,CAAC;AACL,CAAC"}
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "appium-mcp",
3
3
  "mcpName": "io.github.appium/appium-mcp",
4
- "version": "1.5.0",
4
+ "version": "1.6.1",
5
5
  "type": "module",
6
6
  "repository": {
7
7
  "type": "git",
@@ -0,0 +1,278 @@
1
+ import {
2
+ describe,
3
+ test,
4
+ expect,
5
+ beforeEach,
6
+ afterEach,
7
+ jest,
8
+ } from '@jest/globals';
9
+ import { join, isAbsolute } from 'path';
10
+
11
+ /**
12
+ * Local implementation of resolveScreenshotDir for testing.
13
+ * This mirrors the implementation in screenshot.ts to avoid importing
14
+ * the module which has heavy dependencies.
15
+ */
16
+ function resolveScreenshotDir(): string {
17
+ const screenshotDir = process.env.SCREENSHOTS_DIR;
18
+
19
+ if (!screenshotDir) {
20
+ return process.cwd();
21
+ }
22
+
23
+ if (isAbsolute(screenshotDir)) {
24
+ return screenshotDir;
25
+ }
26
+
27
+ return join(process.cwd(), screenshotDir);
28
+ }
29
+
30
+ /**
31
+ * Interface for screenshot dependencies (mirrors screenshot.ts).
32
+ */
33
+ interface ScreenshotDeps {
34
+ getDriver: () => { getScreenshot: () => Promise<string> } | null;
35
+ writeFile: (path: string, data: Buffer) => Promise<void>;
36
+ mkdir: (path: string, options: { recursive: boolean }) => Promise<void>;
37
+ resolveScreenshotDir: () => string;
38
+ dateNow: () => number;
39
+ }
40
+
41
+ /**
42
+ * Local implementation of executeScreenshot for testing.
43
+ * This mirrors the implementation in screenshot.ts.
44
+ */
45
+ async function executeScreenshot(deps: ScreenshotDeps): Promise<any> {
46
+ const driver = deps.getDriver();
47
+ if (!driver) {
48
+ throw new Error('No driver found');
49
+ }
50
+
51
+ try {
52
+ const screenshotBase64 = await driver.getScreenshot();
53
+ const screenshotBuffer = Buffer.from(screenshotBase64, 'base64');
54
+ const timestamp = deps.dateNow();
55
+ const filename = `screenshot_${timestamp}.png`;
56
+ const screenshotDir = deps.resolveScreenshotDir();
57
+
58
+ await deps.mkdir(screenshotDir, { recursive: true });
59
+ const filepath = join(screenshotDir, filename);
60
+ await deps.writeFile(filepath, screenshotBuffer);
61
+
62
+ return {
63
+ content: [
64
+ {
65
+ type: 'text',
66
+ text: `Screenshot saved successfully to: ${filename}`,
67
+ },
68
+ ],
69
+ };
70
+ } catch (err: any) {
71
+ return {
72
+ content: [
73
+ {
74
+ type: 'text',
75
+ text: `Failed to take screenshot. err: ${err.toString()}`,
76
+ },
77
+ ],
78
+ };
79
+ }
80
+ }
81
+
82
+ describe('resolveScreenshotDir', () => {
83
+ const originalEnv = process.env.SCREENSHOTS_DIR;
84
+ const cwd = process.cwd();
85
+
86
+ beforeEach(() => {
87
+ delete process.env.SCREENSHOTS_DIR;
88
+ });
89
+
90
+ afterEach(() => {
91
+ if (originalEnv !== undefined) {
92
+ process.env.SCREENSHOTS_DIR = originalEnv;
93
+ } else {
94
+ delete process.env.SCREENSHOTS_DIR;
95
+ }
96
+ });
97
+
98
+ test('should return process.cwd() when SCREENSHOTS_DIR is not set', () => {
99
+ const result = resolveScreenshotDir();
100
+ expect(result).toBe(cwd);
101
+ });
102
+
103
+ test('should return process.cwd() when SCREENSHOTS_DIR is empty string', () => {
104
+ process.env.SCREENSHOTS_DIR = '';
105
+ const result = resolveScreenshotDir();
106
+ expect(result).toBe(cwd);
107
+ });
108
+
109
+ test('should return absolute path as-is when SCREENSHOTS_DIR is absolute', () => {
110
+ const absolutePath = '/tmp/screenshots';
111
+ process.env.SCREENSHOTS_DIR = absolutePath;
112
+ const result = resolveScreenshotDir();
113
+ expect(result).toBe(absolutePath);
114
+ });
115
+
116
+ test('should join relative path with process.cwd()', () => {
117
+ const relativePath = 'screenshots';
118
+ process.env.SCREENSHOTS_DIR = relativePath;
119
+ const result = resolveScreenshotDir();
120
+ expect(result).toBe(join(cwd, relativePath));
121
+ });
122
+
123
+ test('should handle nested relative paths', () => {
124
+ const relativePath = 'output/screenshots/test';
125
+ process.env.SCREENSHOTS_DIR = relativePath;
126
+ const result = resolveScreenshotDir();
127
+ expect(result).toBe(join(cwd, relativePath));
128
+ });
129
+
130
+ test('should handle relative path starting with ./', () => {
131
+ const relativePath = './screenshots';
132
+ process.env.SCREENSHOTS_DIR = relativePath;
133
+ const result = resolveScreenshotDir();
134
+ expect(result).toBe(join(cwd, relativePath));
135
+ });
136
+
137
+ test('should handle relative path with parent directory reference', () => {
138
+ const relativePath = '../screenshots';
139
+ process.env.SCREENSHOTS_DIR = relativePath;
140
+ const result = resolveScreenshotDir();
141
+ expect(result).toBe(join(cwd, relativePath));
142
+ });
143
+ });
144
+
145
+ describe('executeScreenshot', () => {
146
+ const mockBase64 = 'dGVzdA=='; // 'test' in base64
147
+ const mockTimestamp = 1234567890;
148
+
149
+ function createMockDeps(
150
+ overrides: Partial<ScreenshotDeps> = {}
151
+ ): ScreenshotDeps {
152
+ return {
153
+ getDriver: jest.fn(() => ({
154
+ getScreenshot: jest.fn(() => Promise.resolve(mockBase64)),
155
+ })) as any,
156
+ writeFile: jest.fn(() => Promise.resolve()) as any,
157
+ mkdir: jest.fn(() => Promise.resolve()) as any,
158
+ resolveScreenshotDir: jest.fn(() => '/mock/screenshots') as any,
159
+ dateNow: jest.fn(() => mockTimestamp) as any,
160
+ ...overrides,
161
+ };
162
+ }
163
+
164
+ test('should throw error when no driver found', async () => {
165
+ const deps = createMockDeps({
166
+ getDriver: jest.fn(() => null) as any,
167
+ });
168
+
169
+ await expect(executeScreenshot(deps)).rejects.toThrow('No driver found');
170
+ });
171
+
172
+ test('should return success content with filename', async () => {
173
+ const deps = createMockDeps();
174
+
175
+ const result = await executeScreenshot(deps);
176
+
177
+ expect(result).toEqual({
178
+ content: [
179
+ {
180
+ type: 'text',
181
+ text: `Screenshot saved successfully to: screenshot_${mockTimestamp}.png`,
182
+ },
183
+ ],
184
+ });
185
+ });
186
+
187
+ test('should use resolved screenshot directory from SCREENSHOTS_DIR', async () => {
188
+ const customDir = '/custom/path/screenshots';
189
+ const deps = createMockDeps({
190
+ resolveScreenshotDir: jest.fn(() => customDir) as any,
191
+ });
192
+
193
+ await executeScreenshot(deps);
194
+
195
+ expect(deps.mkdir).toHaveBeenCalledWith(customDir, { recursive: true });
196
+ expect(deps.writeFile).toHaveBeenCalledWith(
197
+ join(customDir, `screenshot_${mockTimestamp}.png`),
198
+ expect.any(Buffer)
199
+ );
200
+ });
201
+
202
+ test('should create directory with recursive option', async () => {
203
+ const deps = createMockDeps();
204
+
205
+ await executeScreenshot(deps);
206
+
207
+ expect(deps.mkdir).toHaveBeenCalledWith('/mock/screenshots', {
208
+ recursive: true,
209
+ });
210
+ });
211
+
212
+ test('should write screenshot buffer to correct filepath', async () => {
213
+ const deps = createMockDeps();
214
+
215
+ await executeScreenshot(deps);
216
+
217
+ expect(deps.writeFile).toHaveBeenCalledWith(
218
+ `/mock/screenshots/screenshot_${mockTimestamp}.png`,
219
+ Buffer.from(mockBase64, 'base64')
220
+ );
221
+ });
222
+
223
+ test('should return error content when screenshot fails', async () => {
224
+ const errorMessage = 'Screenshot capture failed';
225
+ const deps = createMockDeps({
226
+ getDriver: jest.fn(() => ({
227
+ getScreenshot: jest.fn(() => Promise.reject(new Error(errorMessage))),
228
+ })) as any,
229
+ });
230
+
231
+ const result = await executeScreenshot(deps);
232
+
233
+ expect(result).toEqual({
234
+ content: [
235
+ {
236
+ type: 'text',
237
+ text: `Failed to take screenshot. err: Error: ${errorMessage}`,
238
+ },
239
+ ],
240
+ });
241
+ });
242
+
243
+ test('should return error content when mkdir fails', async () => {
244
+ const errorMessage = 'Permission denied';
245
+ const deps = createMockDeps({
246
+ mkdir: jest.fn(() => Promise.reject(new Error(errorMessage))) as any,
247
+ });
248
+
249
+ const result = await executeScreenshot(deps);
250
+
251
+ expect(result).toEqual({
252
+ content: [
253
+ {
254
+ type: 'text',
255
+ text: `Failed to take screenshot. err: Error: ${errorMessage}`,
256
+ },
257
+ ],
258
+ });
259
+ });
260
+
261
+ test('should return error content when writeFile fails', async () => {
262
+ const errorMessage = 'Disk full';
263
+ const deps = createMockDeps({
264
+ writeFile: jest.fn(() => Promise.reject(new Error(errorMessage))) as any,
265
+ });
266
+
267
+ const result = await executeScreenshot(deps);
268
+
269
+ expect(result).toEqual({
270
+ content: [
271
+ {
272
+ type: 'text',
273
+ text: `Failed to take screenshot. err: Error: ${errorMessage}`,
274
+ },
275
+ ],
276
+ });
277
+ });
278
+ });
@@ -1,8 +1,90 @@
1
1
  import { FastMCP } from 'fastmcp/dist/FastMCP.js';
2
- import { z } from 'zod';
3
2
  import { getDriver } from '../../session-store.js';
4
- import { writeFile } from 'fs/promises';
5
- import { join } from 'path';
3
+ import { writeFile, mkdir } from 'fs/promises';
4
+ import { join, isAbsolute } from 'path';
5
+
6
+ /**
7
+ * Resolves the screenshot directory path.
8
+ * - If SCREENSHOTS_DIR is not set, returns process.cwd()
9
+ * - If SCREENSHOTS_DIR is absolute, returns it as-is
10
+ * - If SCREENSHOTS_DIR is relative, joins it with process.cwd()
11
+ */
12
+ export function resolveScreenshotDir(): string {
13
+ const screenshotDir = process.env.SCREENSHOTS_DIR;
14
+
15
+ if (!screenshotDir) {
16
+ return process.cwd();
17
+ }
18
+
19
+ if (isAbsolute(screenshotDir)) {
20
+ return screenshotDir;
21
+ }
22
+
23
+ return join(process.cwd(), screenshotDir);
24
+ }
25
+
26
+ export interface ScreenshotDeps {
27
+ getDriver: () => { getScreenshot: () => Promise<string> } | null;
28
+ writeFile: typeof writeFile;
29
+ mkdir: typeof mkdir;
30
+ resolveScreenshotDir: typeof resolveScreenshotDir;
31
+ dateNow: () => number;
32
+ }
33
+
34
+ const defaultDeps: ScreenshotDeps = {
35
+ getDriver,
36
+ writeFile,
37
+ mkdir,
38
+ resolveScreenshotDir,
39
+ dateNow: () => Date.now(),
40
+ };
41
+
42
+ export async function executeScreenshot(
43
+ deps: ScreenshotDeps = defaultDeps
44
+ ): Promise<any> {
45
+ const driver = deps.getDriver();
46
+ if (!driver) {
47
+ throw new Error('No driver found');
48
+ }
49
+
50
+ try {
51
+ const screenshotBase64 = await driver.getScreenshot();
52
+
53
+ // Convert base64 to buffer
54
+ const screenshotBuffer = Buffer.from(screenshotBase64, 'base64');
55
+
56
+ // Generate filename with timestamp
57
+ const timestamp = deps.dateNow();
58
+ const filename = `screenshot_${timestamp}.png`;
59
+ const screenshotDir = deps.resolveScreenshotDir();
60
+
61
+ // Create a directory if it doesn't exist
62
+ await deps.mkdir(screenshotDir, { recursive: true });
63
+
64
+ const filepath = join(screenshotDir, filename);
65
+
66
+ // Save screenshot to disk
67
+ await deps.writeFile(filepath, screenshotBuffer);
68
+
69
+ return {
70
+ content: [
71
+ {
72
+ type: 'text',
73
+ text: `Screenshot saved successfully to: ${filepath}`,
74
+ },
75
+ ],
76
+ };
77
+ } catch (err: any) {
78
+ return {
79
+ content: [
80
+ {
81
+ type: 'text',
82
+ text: `Failed to take screenshot. err: ${err.toString()}`,
83
+ },
84
+ ],
85
+ };
86
+ }
87
+ }
6
88
 
7
89
  export default function screenshot(server: FastMCP): void {
8
90
  server.addTool({
@@ -13,44 +95,6 @@ export default function screenshot(server: FastMCP): void {
13
95
  readOnlyHint: false,
14
96
  openWorldHint: false,
15
97
  },
16
- execute: async (args: any, context: any): Promise<any> => {
17
- const driver = getDriver();
18
- if (!driver) {
19
- throw new Error('No driver found');
20
- }
21
-
22
- try {
23
- const screenshotBase64 = await driver.getScreenshot();
24
-
25
- // Convert base64 to buffer
26
- const screenshotBuffer = Buffer.from(screenshotBase64, 'base64');
27
-
28
- // Generate filename with timestamp
29
- const timestamp = Date.now();
30
- const filename = `screenshot_${timestamp}.png`;
31
- const filepath = join(process.cwd(), filename);
32
-
33
- // Save screenshot to disk
34
- await writeFile(filepath, screenshotBuffer);
35
-
36
- return {
37
- content: [
38
- {
39
- type: 'text',
40
- text: `Screenshot saved successfully to: ${filename}`,
41
- },
42
- ],
43
- };
44
- } catch (err: any) {
45
- return {
46
- content: [
47
- {
48
- type: 'text',
49
- text: `Failed to take screenshot. err: ${err.toString()}`,
50
- },
51
- ],
52
- };
53
- }
54
- },
98
+ execute: async (): Promise<any> => executeScreenshot(),
55
99
  });
56
100
  }