diff --git a/packages/nocodb/src/modules/jobs/jobs/at-import/at-import.processor.ts b/packages/nocodb/src/modules/jobs/jobs/at-import/at-import.processor.ts
index fb8b028cb2..cda4d2a0ef 100644
--- a/packages/nocodb/src/modules/jobs/jobs/at-import/at-import.processor.ts
+++ b/packages/nocodb/src/modules/jobs/jobs/at-import/at-import.processor.ts
@@ -1,6 +1,5 @@
import moment from 'moment';
import { AuditV1OperationTypes, SqlUiFactory, UITypes } from 'nocodb-sdk';
-import Airtable from 'airtable';
import hash from 'object-hash';
import dayjs from 'dayjs';
import utc from 'dayjs/plugin/utc';
@@ -13,6 +12,7 @@ import { JobsLogService } from '../jobs-log.service';
import FetchAT from './helpers/fetchAT';
import { importData } from './helpers/readAndProcessData';
import EntityMap from './helpers/EntityMap';
+import { ATImportEngine } from './engine';
import type {
AirtableImportFailPayload,
AirtableImportPayload,
@@ -292,7 +292,6 @@ export class AtImportProcessor {
const getAirtableSchema = async (sDB) => {
const start = Date.now();
-
if (!sDB.shareId)
throw {
message:
@@ -319,7 +318,10 @@ export class AtImportProcessor {
const file = ft.schema;
atBaseId = ft.baseId;
- atBase = new Airtable({ apiKey: sDB.apiKey }).base(atBaseId);
+ atBase = ATImportEngine.get().atBase({
+ apiKey: sDB.apiKey,
+ baseId: atBaseId,
+ });
// store copy of airtable schema globally
g_aTblSchema = file.tableSchemas;
diff --git a/packages/nocodb/src/modules/jobs/jobs/at-import/engine/index.ts b/packages/nocodb/src/modules/jobs/jobs/at-import/engine/index.ts
new file mode 100644
index 0000000000..6da39769b3
--- /dev/null
+++ b/packages/nocodb/src/modules/jobs/jobs/at-import/engine/index.ts
@@ -0,0 +1,122 @@
+import axios from 'axios';
+import Airtable from 'airtable';
+import { ATMockImportEngine, MockAirtable } from './mock';
+import type { AirtableBase } from 'airtable/lib/airtable_base';
+import { isPlayWrightNode } from '~/helpers/utils';
+
+export class ATImportEngine extends ATMockImportEngine {
+ static get() {
+ if (isPlayWrightNode()) {
+ return new ATMockImportEngine();
+ }
+ return new ATImportEngine();
+ }
+
+ async initialize({ appId, shareId }: { appId: string; shareId: string }) {
+ const url = `https://airtable.com/${appId ? `${appId}/` : ''}${shareId}`;
+ return await axios.get(url, {
+ headers: {
+ accept:
+ 'text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9',
+ 'accept-language': 'en-US,en;q=0.9',
+ 'sec-ch-ua':
+ '" Not A;Brand";v="99", "Chromium";v="100", "Google Chrome";v="100"',
+ 'sec-ch-ua-mobile': '?0',
+ 'sec-ch-ua-platform': '"Linux"',
+ 'sec-fetch-dest': 'document',
+ 'sec-fetch-mode': 'navigate',
+ 'sec-fetch-site': 'none',
+ 'sec-fetch-user': '?1',
+ 'upgrade-insecure-requests': '1',
+ 'User-Agent':
+ 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.88 Safari/537.36',
+ },
+ // @ts-ignore
+ referrerPolicy: 'strict-origin-when-cross-origin',
+ body: null,
+ method: 'GET',
+ });
+ }
+
+ async read(info: { link: string; cookie: string; headers: any }) {
+ return await axios('https://airtable.com' + info.link, {
+ headers: {
+ accept: '*/*',
+ 'accept-language': 'en-US,en;q=0.9',
+ 'sec-ch-ua':
+ '" Not A;Brand";v="99", "Chromium";v="100", "Google Chrome";v="100"',
+ 'sec-ch-ua-mobile': '?0',
+ 'sec-ch-ua-platform': '"Linux"',
+ 'sec-fetch-dest': 'empty',
+ 'sec-fetch-mode': 'cors',
+ 'sec-fetch-site': 'same-origin',
+ 'User-Agent':
+ 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.88 Safari/537.36',
+ 'x-time-zone': 'Europe/Berlin',
+ cookie: info.cookie,
+ ...info.headers,
+ },
+ // @ts-ignore
+ referrerPolicy: 'no-referrer',
+ body: null,
+ method: 'GET',
+ responseType: 'stream',
+ });
+ }
+
+ async readView(
+ viewId: string,
+ info: { cookie: string; headers: any; baseInfo: any },
+ ) {
+ return await axios(
+ `https://airtable.com/v0.3/view/${viewId}/readData?` +
+ `stringifiedObjectParams=${encodeURIComponent(
+ JSON.stringify({
+ mayOnlyIncludeRowAndCellDataForIncludedViews: true,
+ mayExcludeCellDataForLargeViews: true,
+ }),
+ )}&requestId=${
+ info.baseInfo.requestId
+ }&accessPolicy=${encodeURIComponent(
+ JSON.stringify({
+ allowedActions: info.baseInfo.allowedActions,
+ shareId: info.baseInfo.shareId,
+ applicationId: info.baseInfo.applicationId,
+ generationNumber: info.baseInfo.generationNumber,
+ expires: info.baseInfo.expires,
+ signature: info.baseInfo.signature,
+ }),
+ )}`,
+ {
+ headers: {
+ accept: '*/*',
+ 'accept-language': 'en-US,en;q=0.9',
+ 'sec-ch-ua':
+ '" Not A;Brand";v="99", "Chromium";v="100", "Google Chrome";v="100"',
+ 'sec-ch-ua-mobile': '?0',
+ 'sec-ch-ua-platform': '"Linux"',
+ 'sec-fetch-dest': 'empty',
+ 'sec-fetch-mode': 'cors',
+ 'sec-fetch-site': 'same-origin',
+ 'User-Agent':
+ 'Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.88 Safari/537.36',
+ 'x-time-zone': 'Europe/Berlin',
+ cookie: info.cookie,
+ ...info.headers,
+ },
+ // @ts-ignore
+ referrerPolicy: 'no-referrer',
+ body: null,
+ method: 'GET',
+ responseType: 'stream',
+ },
+ );
+ }
+
+ atBase({ apiKey, baseId }: { apiKey: string; baseId: string }) {
+ if (process.env.DEBUG_MOCK_AIRTABLE_IMPORT === 'true') {
+ return ((title) => new MockAirtable(title)) as any as AirtableBase;
+ }
+ return new Airtable({ apiKey: apiKey }).base(baseId);
+ }
+}
diff --git a/packages/nocodb/src/modules/jobs/jobs/at-import/engine/mock.ts b/packages/nocodb/src/modules/jobs/jobs/at-import/engine/mock.ts
new file mode 100644
index 0000000000..8fa710ee42
--- /dev/null
+++ b/packages/nocodb/src/modules/jobs/jobs/at-import/engine/mock.ts
@@ -0,0 +1,74 @@
+import { Readable } from 'node:stream';
+import {
+ initializeHeader,
+ initializeHtml,
+ readResponse,
+ viewsResponse,
+} from './mockResponses';
+import { mockResponseData } from './mockResponseData';
+import type { AirtableBase } from 'airtable/lib/airtable_base';
+import type { FieldSet, Records } from 'airtable';
+import type { AxiosResponse } from 'axios';
+
+export class ATMockImportEngine {
+ async initialize(_param: { appId: string; shareId: string }) {
+ return {
+ data: initializeHtml, // ← this is what axios returns
+ status: 200,
+ statusText: 'OK',
+ headers: initializeHeader,
+ config: {},
+ } as AxiosResponse;
+ }
+
+ async read(_info: { link: string; cookie: string; headers: any }) {
+ const stream = Readable.from([JSON.stringify(readResponse)]);
+ return {
+ data: stream, // ← this is what axios returns
+ status: 200,
+ statusText: 'OK',
+ headers: {},
+ config: {},
+ } as AxiosResponse;
+ }
+
+ async readView(viewId: string, _info: { baseInfo: any }) {
+ const stream = Readable.from([JSON.stringify(viewsResponse[viewId])]);
+ return {
+ data: stream, // ← this is what axios returns
+ status: 200,
+ statusText: 'OK',
+ headers: {},
+ config: {},
+ } as AxiosResponse;
+ }
+
+ atBase(_param: { apiKey: string; baseId: string }) {
+ return ((title) => new MockAirtable(title)) as any as AirtableBase;
+ }
+}
+
+export class MockAirtable {
+ constructor(protected readonly title: string) {}
+ select(_selectParams: any) {
+ return this;
+ }
+ eachPage(
+ pageHandle: (
+ records: Records